vdi-cloud/platform/internal/server/server_test.go

392 lines
16 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 { return newFixtureWorker(t, true) }
func newFixtureWorker(t *testing.T, worker bool) *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 terminal_metric_samples,terminal_metrics_current,power_batch_items,power_batches,snapshot_jobs,hardware_jobs,pve_connection,login_limits,alert_states,alerts,alert_rules,resource_samples,session_history,import_batches,terminal_policy_deliveries,terminal_policy_deployments,terminal_policy_templates,provisioning_jobs,desktop_pools,image_templates,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)
workerDone := make(chan struct{})
go func() {
defer close(workerDone)
if worker {
s.RunWorker(ctx)
}
}()
t.Cleanup(func() { cancel(); <-workerDone; 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'")
f.db.Exec("UPDATE desktops SET setup_required=true WHERE id='desk_demo_windows'")
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 409, status, out)
f.db.Exec("UPDATE desktops SET setup_required=false 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")
}
status, out = f.request("GET", "/terminal-logs?terminalId="+id+"&keyword=private", admin, nil, "")
expect(t, 200, status, out)
if out["total"] != float64(1) || len(out["items"].([]any)) != 1 || out["items"].([]any)[0].(map[string]any)["logText"] != "private diagnostic" {
t.Fatalf("terminal log archive not searchable: %#v", out)
}
_ = 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)
}
})
}
}