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

221 lines
11 KiB
Go

package server
import (
"context"
"encoding/json"
"strings"
"testing"
"vdi-cloud/platform/internal/store"
)
func lifecycleDevice(t *testing.T, f *fixture, admin, sn string) (string, string, string) {
t.Helper()
status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": "测试设备", "group": "原分组"}, "")
expect(t, 201, status, out)
id, secret := out["id"].(string), out["activationSecret"].(string)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 200, status, out)
return id, out["accessToken"].(string), secret
}
func lifecycleBind(t *testing.T, f *fixture, admin, id string) {
t.Helper()
if _, err := f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100',power_state='running' WHERE id='desk_demo_windows'"); err != nil {
t.Fatal(err)
}
status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows"}, "enabled": true}, "")
expect(t, 200, status, out)
}
func TestTerminalLifecycleRevokesIdentityAndTickets(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
id, device, originalSecret := lifecycleDevice(t, f, admin, "LIFECYCLE-001")
lifecycleBind(t, f, admin, id)
status, out := f.request("PUT", "/terminals/"+id, admin, map[string]any{"name": " 新设备名 ", "group": " 财务 "}, "")
expect(t, 200, status, out)
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", "/terminals/"+id+"/disable", admin, nil, "")
expect(t, 200, status, out)
for _, path := range []string{"/terminal/config", "/terminal/desktops", "/terminal/commands"} {
status, out = f.request("GET", path, device, nil, "")
expect(t, 401, status, out)
}
status, out = f.request("POST", "/terminal/heartbeat", device, map[string]any{"agentVersion": "stale"}, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": originalSecret, "agentVersion": "stale"}, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "disabled-command")
expect(t, 409, status, out)
status, out = f.request("GET", "/terminals", admin, nil, "")
expect(t, 200, status, out)
var found bool
for _, raw := range out["items"].([]any) {
item := raw.(map[string]any)
if item["id"] == id {
found = true
if item["status"] != "disabled" || item["enabled"] != false || item["name"] != "新设备名" || item["group"] != "财务" {
t.Fatal("incorrect terminal state", item)
}
}
}
if !found {
t.Fatal("terminal disappeared")
}
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
t.Fatal(err)
}
var enabled bool
if err := f.db.Get(&enabled, "SELECT enabled FROM terminals WHERE id=$1", id); err != nil || enabled {
t.Fatal("restart enabled disabled terminal", err)
}
status, out = f.request("POST", "/terminals/"+id+"/reactivate", admin, nil, "")
expect(t, 200, status, out)
firstSecret := out["activationSecret"].(string)
status, out = f.request("POST", "/terminals/"+id+"/reactivate", admin, nil, "")
expect(t, 200, status, out)
secret := out["activationSecret"].(string)
if secret == firstSecret || secret == originalSecret {
t.Fatal("reactivation reused credentials")
}
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": firstSecret, "agentVersion": "test"}, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 200, status, out)
newDevice := out["accessToken"].(string)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/connect-ticket/consume", newDevice, map[string]any{"ticket": ticket}, "")
expect(t, 401, status, out)
status, out = f.request("GET", "/terminal/desktops", newDevice, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 1 {
t.Fatal("reactivation lost authorization")
}
status, out = f.request("GET", "/terminal/config", device, nil, "")
expect(t, 401, status, out)
status, out = f.request("GET", "/audit-logs", admin, nil, "")
expect(t, 200, status, out)
audit, _ := json.Marshal(out)
if strings.Contains(string(audit), secret) || strings.Contains(string(audit), newDevice) || !strings.Contains(string(audit), "reactivate_terminal") {
t.Fatal("audit missing or leaked credentials")
}
}
func TestTerminalLifecycleGuardsSessionsTasksAndRoles(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
id, device, _ := lifecycleDevice(t, f, admin, "LIFECYCLE-002")
lifecycleBind(t, f, admin, id)
for _, role := range []string{"viewer", "operator"} {
token := f.login(role)
for _, path := range []string{"/disable", "/reactivate"} {
status, out := f.request("POST", "/terminals/"+id+path, token, nil, "")
expect(t, 403, status, out)
}
status, out := f.request("PUT", "/terminals/"+id, token, map[string]any{"name": "无权限", "group": ""}, "")
expect(t, 403, status, out)
status, out = f.request("PUT", "/users/user_demo_wang", token, map[string]any{"displayName": "无权限", "enabled": false}, "")
expect(t, 403, status, out)
status, out = f.request("POST", "/users/batch-bindings", token, map[string]any{"userIds": []string{"user_demo_wang"}, "desktopIds": []string{"desk_demo_windows"}, "action": "revoke"}, "")
expect(t, 403, status, out)
}
status, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 201, status, out)
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": out["ticket"]}, "")
expect(t, 200, status, out)
session := out["sessionId"].(string)
for _, path := range []string{"/disable", "/reactivate"} {
status, out = f.request("POST", "/terminals/"+id+path, admin, nil, "")
expect(t, 409, status, out)
if out["code"] != "terminal_in_use" {
t.Fatal(out)
}
}
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "ended"}, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "pending-command")
expect(t, 202, status, out)
task := out["id"].(string)
for _, path := range []string{"/disable", "/reactivate"} {
status, out = f.request("POST", "/terminals/"+id+path, admin, nil, "")
expect(t, 409, status, out)
if out["code"] != "terminal_task_pending" {
t.Fatal(out)
}
}
status, out = f.request("GET", "/terminal/commands", device, nil, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/commands/"+task+"/ack", device, map[string]any{"status": "failed"}, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminals/"+id+"/disable", admin, nil, "")
expect(t, 200, status, out)
status, out = f.request("PUT", "/terminals/"+id, admin, map[string]any{"name": " ", "group": ""}, "")
expect(t, 400, status, out)
status, out = f.request("POST", "/terminals/missing/reactivate", admin, nil, "")
expect(t, 404, status, out)
}
func TestUserMaintenanceAndBatchBindingsAreAtomic(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
id, device, _ := lifecycleDevice(t, f, admin, "LIFECYCLE-003")
lifecycleBind(t, f, admin, id)
status, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 201, status, out)
ticket := out["ticket"]
status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": " 新姓名 ", "enabled": false}, "")
expect(t, 200, status, out)
if out["displayName"] != "新姓名" || out["username"] != "wang.demo" || len(out["desktopIds"].([]any)) != 1 {
t.Fatal("profile update changed authorization", out)
}
status, out = f.request("GET", "/terminal/desktops", device, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 0 {
t.Fatal("disabled user still authorized")
}
status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": "新姓名", "enabled": true}, "")
expect(t, 200, 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("POST", "/users", admin, map[string]any{"username": "batch-other", "displayName": "其他用户"}, "")
expect(t, 201, status, out)
other := out["id"].(string)
body := map[string]any{"userIds": []string{"user_demo_wang", other, other}, "desktopIds": []string{"desk_demo_ubuntu", "desk_demo_ubuntu"}, "action": "grant"}
for i := 0; i < 2; i++ {
status, out = f.request("POST", "/users/batch-bindings", admin, body, "")
expect(t, 200, status, out)
if out["userCount"] != float64(2) {
t.Fatal(out)
}
}
var count int
if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 2 {
t.Fatal("grant replaced existing grants", count, err)
}
body["desktopIds"] = []string{"desk_demo_ubuntu", "missing"}
body["action"] = "revoke"
status, out = f.request("POST", "/users/batch-bindings", admin, body, "")
expect(t, 400, status, out)
f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
if count != 2 {
t.Fatal("invalid batch partially committed")
}
body["desktopIds"] = []string{"desk_demo_ubuntu"}
body["userIds"] = []string{"user_demo_wang", "missing"}
status, out = f.request("POST", "/users/batch-bindings", admin, body, "")
expect(t, 400, status, out)
body["userIds"] = []string{"user_demo_wang", other}
status, out = f.request("POST", "/users/batch-bindings", admin, body, "")
expect(t, 200, status, out)
f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
if count != 1 {
t.Fatal("revoke removed unrelated desktop")
}
status, out = f.request("POST", "/users/batch-bindings", admin, map[string]any{"userIds": []string{}, "desktopIds": []string{}, "action": "grant"}, "")
expect(t, 400, status, out)
status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": " ", "enabled": true}, "")
expect(t, 400, status, out)
}