功能:新增云管API与Web控制台并完成现场接入
This commit is contained in:
1 parent
18a9ce995d
commit
3fb7b4da8c
79 files changed
+17367
-3
No files matched your search
@@ -0,0 +1,375 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user