376 lines
15 KiB
Go
376 lines
15 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/jmoiron/sqlx"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"vdi-cloud/platform/internal/config"
|
|
"vdi-cloud/platform/internal/model"
|
|
"vdi-cloud/platform/internal/store"
|
|
"vdi-cloud/platform/pkg/proxmox"
|
|
)
|
|
|
|
type fixture struct {
|
|
server *Server
|
|
db *sqlx.DB
|
|
t *testing.T
|
|
}
|
|
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
url := os.Getenv("VDI_TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("set VDI_TEST_DATABASE_URL to a dedicated empty test database")
|
|
}
|
|
ctx := context.Background()
|
|
db, err := store.Open(ctx, url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = db.Exec(`TRUNCATE connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := New(config.Config{Mode: "demo", JWTSecret: strings.Repeat("test-", 10)}, db, nil)
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
go s.RunWorker(ctx)
|
|
t.Cleanup(func() { cancel(); time.Sleep(30 * time.Millisecond); db.Close() })
|
|
return &fixture{s, db, t}
|
|
}
|
|
func (f *fixture) request(method, path, token string, body any, key string) (int, map[string]any) {
|
|
f.t.Helper()
|
|
var encoded []byte
|
|
if body != nil {
|
|
encoded, _ = json.Marshal(body)
|
|
}
|
|
r := httptest.NewRequest(method, "/api/v1"+path, bytes.NewReader(encoded))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
if key != "" {
|
|
r.Header.Set("Idempotency-Key", key)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
f.server.Router.ServeHTTP(w, r)
|
|
out := map[string]any{}
|
|
if w.Body.Len() > 0 {
|
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
}
|
|
return w.Code, out
|
|
}
|
|
func (f *fixture) login(user string) string {
|
|
f.t.Helper()
|
|
status, out := f.request("POST", "/auth/login", "", map[string]any{"username": user, "password": "Demo@2026"}, "")
|
|
if status != 200 {
|
|
f.t.Fatalf("login failed %d %v", status, out)
|
|
}
|
|
return out["accessToken"].(string)
|
|
}
|
|
func expect(t *testing.T, want, got int, out any) {
|
|
t.Helper()
|
|
if want != got {
|
|
t.Fatalf("want status %d got %d: %v", want, got, out)
|
|
}
|
|
}
|
|
|
|
func TestRBACLogoutAndDurableTask(t *testing.T) {
|
|
f := newFixture(t)
|
|
viewer := f.login("viewer")
|
|
operator := f.login("operator")
|
|
admin := f.login("admin")
|
|
status, out := f.request("GET", "/desktops", viewer, nil, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", viewer, map[string]any{"action": "start"}, "viewer-1")
|
|
expect(t, 403, status, out)
|
|
status, out = f.request("PUT", "/users/user_demo_wang/bindings", operator, map[string]any{"desktopIds": []string{}}, "")
|
|
expect(t, 403, status, out)
|
|
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
|
|
expect(t, 202, status, out)
|
|
id := out["id"].(string)
|
|
status, again := f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
|
|
expect(t, 202, status, again)
|
|
if again["id"] != id {
|
|
t.Fatal("duplicate task created")
|
|
}
|
|
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "shutdown"}, "start-1")
|
|
expect(t, 409, status, out)
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
|
|
expect(t, 200, status, out)
|
|
if out["status"] == "success" {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("task never completed", out)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
var power, service string
|
|
if err := f.db.QueryRow("SELECT power_state,service_state FROM desktops WHERE id='desk_demo_kylin'").Scan(&power, &service); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if power != "running" || service != "unknown" {
|
|
t.Fatal("power and service state confused")
|
|
}
|
|
status, out = f.request("POST", "/auth/logout", admin, nil, "")
|
|
expect(t, 204, status, out)
|
|
status, out = f.request("GET", "/auth/me", admin, nil, "")
|
|
expect(t, 401, status, out)
|
|
var count int
|
|
f.db.Get(&count, "SELECT count(*) FROM tasks WHERE id=$1", id)
|
|
if count != 1 {
|
|
t.Fatal("task not persisted")
|
|
}
|
|
}
|
|
|
|
func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.login("admin")
|
|
enroll := func(sn string) (string, string) {
|
|
status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": sn, "group": "测试"}, "")
|
|
expect(t, 201, status, out)
|
|
secret := out["activationSecret"].(string)
|
|
id := out["id"].(string)
|
|
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
|
|
expect(t, 200, status, out)
|
|
token := out["accessToken"].(string)
|
|
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
|
|
expect(t, 401, status, out)
|
|
return id, token
|
|
}
|
|
id, device := enroll("TEST-DEVICE-001")
|
|
otherID, other := enroll("TEST-DEVICE-002")
|
|
status, out := f.request("GET", "/users", device, nil, "")
|
|
expect(t, 401, status, out)
|
|
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
|
expect(t, 403, status, out)
|
|
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows", "desk_demo_ubuntu"}, "enabled": true}, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("GET", "/terminal/desktops", device, nil, "")
|
|
expect(t, 200, status, out)
|
|
if len(out["items"].([]any)) != 2 {
|
|
t.Fatal("missing allowed desktops")
|
|
}
|
|
f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100' WHERE id='desk_demo_windows'")
|
|
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
|
expect(t, 201, status, out)
|
|
ticket := out["ticket"].(string)
|
|
status, out = f.request("POST", "/terminal/connect-ticket/consume", other, map[string]any{"ticket": ticket}, "")
|
|
expect(t, 401, status, out)
|
|
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
|
|
expect(t, 200, status, out)
|
|
if out["credentialMode"] != "existing-pairing" {
|
|
t.Fatal("must declare existing Sunshine pairing")
|
|
}
|
|
sessionID := out["sessionId"].(string)
|
|
status, out = f.request("GET", "/overview", admin, nil, "")
|
|
expect(t, 200, status, out)
|
|
if out["activeSessions"] != float64(0) {
|
|
t.Fatal("reservation counted as active stream")
|
|
}
|
|
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("GET", "/overview", admin, nil, "")
|
|
if out["activeSessions"] != float64(1) {
|
|
t.Fatal("stream-ready event missing")
|
|
}
|
|
status, out = f.request("POST", "/terminal/session", other, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
|
|
expect(t, 409, status, out)
|
|
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
|
|
expect(t, 409, status, out)
|
|
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
|
|
expect(t, 401, status, out)
|
|
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{}, "enabled": true}, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
|
expect(t, 403, status, out)
|
|
status, out = f.request("POST", fmt.Sprintf("/terminals/%s/commands", id), admin, map[string]any{"action": "collect-logs"}, "cmd-1")
|
|
expect(t, 202, status, out)
|
|
taskID := out["id"].(string)
|
|
status, out = f.request("GET", "/terminal/commands", other, nil, "")
|
|
expect(t, 200, status, out)
|
|
if len(out["items"].([]any)) != 0 {
|
|
t.Fatal("command leaked to other terminal")
|
|
}
|
|
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", other, map[string]any{"status": "success"}, "")
|
|
expect(t, 404, status, out)
|
|
status, out = f.request("GET", "/terminal/commands", device, nil, "")
|
|
expect(t, 200, status, out)
|
|
if len(out["items"].([]any)) != 1 {
|
|
t.Fatal("device did not receive command")
|
|
}
|
|
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", device, map[string]any{"status": "failed", "message": "private device log"}, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("GET", "/tasks/"+taskID, admin, nil, "")
|
|
expect(t, 200, status, out)
|
|
if out["status"] != "failed" {
|
|
t.Fatal("must wait for device ack")
|
|
}
|
|
if strings.Contains(out["message"].(string), "private") {
|
|
t.Fatal("raw device detail leaked")
|
|
}
|
|
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "diagnostics-2")
|
|
expect(t, 202, status, out)
|
|
diagnosticID := out["id"].(string)
|
|
status, out = f.request("GET", "/terminal/commands", device, nil, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success"}, "")
|
|
expect(t, 400, status, out)
|
|
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success", "diagnostics": map[string]any{"agentVersion": "qt-test", "bootId": "test-boot", "kioskState": "DESKTOP_LIST", "uptimeSeconds": 100, "logTail": "private diagnostic"}}, "")
|
|
expect(t, 200, status, out)
|
|
viewer := f.login("viewer")
|
|
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", viewer, nil, "")
|
|
expect(t, 403, status, out)
|
|
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", admin, nil, "")
|
|
expect(t, 200, status, out)
|
|
if out["logTail"] != "private diagnostic" {
|
|
t.Fatal("diagnostic not retained")
|
|
}
|
|
_ = otherID
|
|
}
|
|
|
|
func TestUnknownTerminalOutcomeAndBindingRollback(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.login("admin")
|
|
status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": "term_demo_001", "desktopIds": []string{"missing"}}, "")
|
|
expect(t, 400, status, out)
|
|
var n int
|
|
f.db.Get(&n, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
|
|
if n != 2 {
|
|
t.Fatal("failed binding update lost original authorization")
|
|
}
|
|
status, out = f.request("POST", "/terminals/term_demo_001/commands", admin, map[string]any{"action": "reboot"}, "offline-1")
|
|
expect(t, 202, status, out)
|
|
id := out["id"].(string)
|
|
f.db.Exec("UPDATE tasks SET created_at=now()-interval '3 minutes' WHERE id=$1", id)
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
|
|
expect(t, 200, status, out)
|
|
if out["status"] == "timeout" {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("offline task was not timed out", out)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
status, out = f.request(http.MethodPost, "/resources/sync", admin, nil, "sync-1")
|
|
expect(t, 503, status, out)
|
|
}
|
|
|
|
func TestRestartPreservesRevokedBindingsAndRejectsModeChange(t *testing.T) {
|
|
f := newFixture(t)
|
|
if _, err := f.db.Exec("DELETE FROM bindings WHERE user_id='user_demo_wang'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var count int
|
|
if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 0 {
|
|
t.Fatalf("restart restored revoked bindings: count=%d err=%v", count, err)
|
|
}
|
|
if err := store.Bootstrap(context.Background(), f.db, "production", "prod-admin", "long-password-2026"); err == nil {
|
|
t.Fatal("demo database accepted production bootstrap")
|
|
}
|
|
}
|
|
|
|
func TestInitialPasswordPolicyDoesNotRejectExistingAccountOnRestart(t *testing.T) {
|
|
f := newFixture(t)
|
|
if _, err := f.db.Exec("UPDATE platform_metadata SET mode='production'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.db.Exec("UPDATE platform_metadata SET mode='demo'")
|
|
ctx := context.Background()
|
|
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err == nil {
|
|
t.Fatal("weak initial password accepted")
|
|
}
|
|
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "strong-initial-password"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var before, after string
|
|
if err := f.db.Get(&before, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err != nil {
|
|
t.Fatal("existing account blocked startup", err)
|
|
}
|
|
if err := f.db.Get(&after, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if before != after {
|
|
t.Fatal("restart changed the account password")
|
|
}
|
|
}
|
|
|
|
func TestLateCommandAcknowledgementPreservesNewSession(t *testing.T) {
|
|
f := newFixture(t)
|
|
if _, err := f.db.Exec("UPDATE terminals SET session_id='new-session',session_state='connected',session_desktop_id='desk_demo_windows' WHERE id='term_demo_001'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var task model.Task
|
|
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES('task-late','terminal','reset-session','term_demo_001','test','admin','running','','late') RETURNING *`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.server.complete(context.Background(), task, "success", "ack"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var session string
|
|
if err := f.db.Get(&session, "SELECT session_id FROM terminals WHERE id='term_demo_001'"); err != nil || session != "new-session" {
|
|
t.Fatalf("new session lost: %q %v", session, err)
|
|
}
|
|
}
|
|
|
|
func TestPVEConfirmedFailureAndUncertainSubmission(t *testing.T) {
|
|
f := newFixture(t)
|
|
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox' WHERE id='desk_demo_kylin'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
submission int
|
|
exit string
|
|
want string
|
|
}{{"submission unavailable", 503, "", "timeout"}, {"permission denied", 403, "", "failed"}, {"explicit PVE task failure", 200, "ERROR", "failed"}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
pve := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.Method == http.MethodPost {
|
|
w.WriteHeader(tc.submission)
|
|
fmt.Fprint(w, `{"data":"UPID:test"}`)
|
|
return
|
|
}
|
|
fmt.Fprintf(w, `{"data":{"status":"stopped","exitstatus":%q}}`, tc.exit)
|
|
}))
|
|
defer pve.Close()
|
|
f.server.PVE = &proxmox.Client{BaseURL: pve.URL, HTTP: pve.Client()}
|
|
var task model.Task
|
|
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'desktop','start','desk_demo_kylin','test VM','admin','running','test',$1) RETURNING *`, store.ID("task_")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.server.execute(context.Background(), task)
|
|
var status string
|
|
if err := f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID); err != nil || status != tc.want {
|
|
t.Fatalf("want %s, got %s err=%v", tc.want, status, err)
|
|
}
|
|
})
|
|
}
|
|
}
|