package server import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "vdi-cloud/platform/pkg/proxmox" ) func TestCSVParsingAndAtomicImport(t *testing.T) { rows, message := parseImport(importInput{Kind: "users", CSV: "\ufeffusername,displayName\r\nworker,\"研发,一组\"\r\nworker,重复\r\n"}) if message != "" || len(rows) != 2 || rows[0].Values["displayName"] != "研发,一组" || len(rows[1].Errors) == 0 { t.Fatalf("CSV parsing: %v %s", rows, message) } f := newFixture(t) admin := f.login("admin") viewer := f.login("viewer") in := map[string]any{"kind": "users", "csv": "username,displayName\nimported,导入用户\n"} code, out := f.request("POST", "/imports/preview", viewer, in, "") expect(t, 403, code, out) code, out = f.request("POST", "/imports/preview", admin, in, "") expect(t, 200, code, out) if out["validRows"] != float64(1) { t.Fatal(out) } in["previewToken"] = out["previewToken"] var count int f.db.Get(&count, "SELECT count(*) FROM users WHERE username='imported'") if count != 0 { t.Fatal("preview wrote a user") } code, out = f.request("POST", "/imports/commit", admin, in, "csv-users") expect(t, 200, code, out) code, out = f.request("POST", "/imports/commit", admin, in, "csv-users") expect(t, 200, code, out) if out["replayed"] != true { t.Fatal("replay not recognized") } bad := map[string]any{"kind": "users", "csv": "username,displayName\nfresh,有效\nimported,已存在\n"} code, out = f.request("POST", "/imports/preview", admin, bad, "") expect(t, 200, code, out) if out["errorRows"] != float64(1) { t.Fatal(out) } bad["previewToken"] = strings.Repeat("0", 64) code, out = f.request("POST", "/imports/commit", admin, bad, "csv-invalid") expect(t, 409, code, out) f.db.Get(&count, "SELECT count(*) FROM users WHERE username='fresh'") if count != 0 { t.Fatal("partial import") } terminals := map[string]any{"kind": "terminals", "csv": "sn,name,group\nCSV-TERM-001,批量终端,研发\n"} code, out = f.request("POST", "/imports/preview", admin, terminals, "") expect(t, 200, code, out) terminals["previewToken"] = out["previewToken"] code, out = f.request("POST", "/imports/commit", admin, terminals, "csv-terminals") expect(t, 200, code, out) keys := out["activationSecrets"].([]any) if len(keys) != 1 { t.Fatal(out) } secret := keys[0].(map[string]any)["activationSecret"].(string) var stored string f.db.Get(&stored, "SELECT result::text FROM import_batches WHERE id=$1", out["id"]) if strings.Contains(stored, secret) { t.Fatal("plaintext activation secret persisted") } code, out = f.request("POST", "/imports/commit", admin, terminals, "csv-terminals") expect(t, 200, code, out) if len(out["activationSecrets"].([]any)) != 0 { t.Fatal("replay exposed credentials") } binding := map[string]any{"kind": "bindings", "csv": "username,terminalSn,desktopIds\nimported,CSV-TERM-001,desk_demo_windows\n"} code, out = f.request("POST", "/imports/preview", admin, binding, "") expect(t, 200, code, out) binding["previewToken"] = out["previewToken"] var uid string f.db.Get(&uid, "SELECT id FROM users WHERE username='imported'") if _, err := f.db.Exec("INSERT INTO bindings(user_id,desktop_id) VALUES($1,'desk_demo_ubuntu')", uid); err != nil { t.Fatal(err) } code, out = f.request("POST", "/imports/commit", admin, binding, "csv-bindings") expect(t, 409, code, out) code, out = f.request("POST", "/imports/preview", admin, binding, "") expect(t, 200, code, out) binding["previewToken"] = out["previewToken"] code, out = f.request("POST", "/imports/commit", admin, binding, "csv-bindings") expect(t, 200, code, out) f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id=$1", uid) if count != 2 { t.Fatal("existing grant was lost") } var sn string f.db.Get(&sn, "SELECT t.sn FROM users u JOIN terminals t ON t.id=u.terminal_id WHERE u.id=$1", uid) if sn != "CSV-TERM-001" { t.Fatal("terminal not bound") } } func TestSessionHistoryLifecycle(t *testing.T) { f := newFixture(t) admin := f.login("admin") code, out := f.request("POST", "/terminals", admin, map[string]any{"sn": "HISTORY-001", "name": "历史终端", "group": "测试"}, "") expect(t, 201, code, out) id := out["id"].(string) code, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "HISTORY-001", "activationSecret": out["activationSecret"], "agentVersion": "test"}, "") expect(t, 200, code, out) device := out["accessToken"].(string) code, 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, code, out) if _, err := f.db.Exec("UPDATE desktops SET endpoint='192.0.2.1' WHERE id='desk_demo_windows'"); err != nil { t.Fatal(err) } reserve := func() string { code, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "") expect(t, 201, code, out) code, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": out["ticket"]}, "") expect(t, 200, code, out) return out["sessionId"].(string) } session := reserve() var row sessionRecord if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session); err != nil { t.Fatal(err) } if row.State != "connecting" || row.ConnectedAt != nil { t.Fatal("reservation claimed stream ready") } code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "connected"}, "") expect(t, 200, code, out) f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session) first := *row.ConnectedAt code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "connected"}, "") expect(t, 200, code, out) f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session) if !row.ConnectedAt.Equal(first) { t.Fatal("connected time reset") } if _, err := f.db.Exec("UPDATE terminals SET name='改名' WHERE id=$1", id); err != nil { t.Fatal(err) } second := reserve() code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "ended"}, "") expect(t, 409, code, out) f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session) if row.EndReason != "replaced" || row.TerminalName != "历史终端" { t.Fatal("lost snapshot or replacement") } if _, err := f.db.Exec("UPDATE session_history SET last_reported_at=now()-interval '100 seconds' WHERE session_id=$1", second); err != nil { t.Fatal(err) } if err := f.server.observe(context.Background()); err != nil { t.Fatal(err) } code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": second, "state": "connected"}, "") expect(t, 200, code, out) if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1 AND ended_at IS NULL", second); err != nil { t.Fatal(err) } if row.Source != "observed" || row.ConnectedAt == nil { t.Fatal("did not resume observed history") } if _, err := f.db.Exec("UPDATE session_history SET last_reported_at=now()-interval '100 seconds' WHERE session_id=$1 AND ended_at IS NULL", second); err != nil { t.Fatal(err) } if err := f.server.observe(context.Background()); err != nil { t.Fatal(err) } code, out = f.request("POST", "/terminal/heartbeat", device, map[string]any{"agentVersion": "test"}, "") expect(t, 200, code, out) if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1 AND ended_at IS NULL", second); err != nil { t.Fatal(err) } if row.Source != "observed" || row.State != "connected" { t.Fatal("heartbeat did not resume observed history") } code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": second, "state": "ended"}, "") expect(t, 200, code, out) code, out = f.request("GET", "/sessions/history?keyword="+url.QueryEscape("历史终端")+"&pageSize=1", admin, nil, "") expect(t, 200, code, out) if out["total"] != float64(1) || len(out["items"].([]any)) != 1 { t.Fatal(out) } code, out = f.request("GET", "/sessions/history?status=invalid", admin, nil, "") expect(t, 400, code, out) } func TestMonitoringHoldAcknowledgeRecoveryAndSeries(t *testing.T) { f := newFixture(t) admin := f.login("admin") viewer := f.login("viewer") operator := f.login("operator") rule := map[string]any{"name": "测试CPU", "metric": "node_cpu", "threshold": 90, "holdSeconds": 120, "enabled": true} code, out := f.request("POST", "/monitor/rules", viewer, rule, "") expect(t, 403, code, out) code, out = f.request("POST", "/monitor/rules", admin, rule, "") expect(t, 200, code, out) rid := out["id"].(string) if _, err := f.db.Exec("INSERT INTO resources VALUES('test-node','测试节点','online',0.95,0.5,0.5,'proxmox',now()); INSERT INTO resource_samples(resource_id,cpu,memory) VALUES('test-node',0.95,0.5)"); err != nil { t.Fatal(err) } evaluate := func() { t.Helper() if err := f.server.observe(context.Background()); err != nil { t.Fatal(err) } } evaluate() var count int f.db.Get(&count, "SELECT count(*) FROM alerts") if count != 0 { t.Fatal("triggered before hold") } if _, err := f.db.Exec("UPDATE alert_states SET first_seen=now()-interval '121 seconds'"); err != nil { t.Fatal(err) } evaluate() evaluate() f.db.Get(&count, "SELECT count(*) FROM alerts") if count != 1 { t.Fatal("alert missing or duplicated") } var aid string f.db.Get(&aid, "SELECT id FROM alerts") code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", viewer, nil, "") expect(t, 403, code, out) code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", operator, nil, "") expect(t, 200, code, out) code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", admin, nil, "") expect(t, 200, code, out) var actor string f.db.Get(&actor, "SELECT acknowledged_by FROM alerts WHERE id=$1", aid) if actor != "operator" { t.Fatal("first acknowledgment overwritten") } if _, err := f.db.Exec("UPDATE resources SET updated_at=now()-interval '100 seconds' WHERE id='test-node'"); err != nil { t.Fatal(err) } evaluate() var state string f.db.Get(&state, "SELECT status FROM alerts WHERE id=$1", aid) if state != "firing" { t.Fatal("missing data claimed recovery") } if _, err := f.db.Exec("UPDATE resources SET updated_at=now() WHERE id='test-node'; UPDATE resource_samples SET cpu=0.1"); err != nil { t.Fatal(err) } evaluate() f.db.Get(&state, "SELECT status FROM alerts WHERE id=$1", aid) if state != "resolved" { t.Fatal("did not recover") } code, out = f.request("GET", "/monitor/series?resourceId=test-node", viewer, nil, "") expect(t, 200, code, out) points := out["items"].([]any) if len(points) != 1 || points[0].(map[string]any)["storage"] != nil || points[0].(map[string]any)["cpu"] != float64(10) { t.Fatal(out) } rule["enabled"] = false code, out = f.request("PUT", "/monitor/rules/"+rid, admin, rule, "") expect(t, 200, code, out) code, out = f.request("GET", "/monitor/alerts?status=resolved&pageSize=1", viewer, nil, "") expect(t, 200, code, out) if out["total"] != float64(1) { t.Fatal(out) } } func TestNodeAndTerminalOfflineThresholds(t *testing.T) { f := newFixture(t) admin := f.login("admin") for _, metric := range []string{"node_unavailable", "terminal_offline"} { code, out := f.request("POST", "/monitor/rules", admin, map[string]any{"name": metric, "metric": metric, "threshold": 120, "holdSeconds": 0, "enabled": true}, "") expect(t, 200, code, out) } if _, err := f.db.Exec(`INSERT INTO resources VALUES('offline-node','离线节点','offline',0,0,0,'proxmox',now()); INSERT INTO terminals(id,sn,name,bootstrap_hash,token_hash,last_seen,status) VALUES('offline-box','OFFLINE-001','离线终端','hash','token',now()-interval '130 seconds','online'),('new-box','NEW-001','未激活终端','hash',NULL,NULL,'unregistered')`); err != nil { t.Fatal(err) } if err := f.server.observe(context.Background()); err != nil { t.Fatal(err) } var count int f.db.Get(&count, "SELECT count(*) FROM alerts") if count != 1 { t.Fatal("offline node fired early or inactive terminal included") } if _, err := f.db.Exec("UPDATE alert_states SET first_seen=now()-interval '121 seconds' WHERE target_id='offline-node'"); err != nil { t.Fatal(err) } if err := f.server.observe(context.Background()); err != nil { t.Fatal(err) } f.db.Get(&count, "SELECT count(*) FROM alerts WHERE status='firing'") if count != 2 { t.Fatal("node never fired") } if _, err := f.db.Exec("UPDATE terminals SET enabled=false WHERE id='offline-box'; UPDATE resources SET status='online',updated_at=now() WHERE id='offline-node'"); err != nil { t.Fatal(err) } if err := f.server.observe(context.Background()); err != nil { t.Fatal(err) } f.db.Get(&count, "SELECT count(*) FROM alerts WHERE status='firing'") if count != 0 { t.Fatal("disabled or recovered target stayed firing") } } // Ensure nullable fields retain their JSON representation for clients drawing gaps. func TestResourcePointNullJSON(t *testing.T) { b, err := json.Marshal(resourcePoint{Time: time.Now()}) if err != nil || !strings.Contains(string(b), `"cpu":null`) { t.Fatal(string(b), err) } } func TestPVESyncPersistsSamplesAndOfflineGaps(t *testing.T) { f := newFixture(t) online := true srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { status := "offline" if online { status = "online" } json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"type": "node", "node": "sample-node", "status": status, "cpu": 0.4, "mem": float64(16 << 30), "maxmem": float64(32 << 30), "disk": float64(40 << 30), "maxdisk": float64(100 << 30)}}}) })) defer srv.Close() f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()} if err := f.server.sync(context.Background()); err != nil { t.Fatal(err) } var point resourcePoint if err := f.db.Get(&point, "SELECT sampled_at time,cpu,memory,storage,1 samples FROM resource_samples WHERE resource_id='sample-node'"); err != nil { t.Fatal(err) } if point.Memory == nil || *point.Memory != 0.5 { t.Fatal("incorrect memory ratio") } online = false if err := f.server.sync(context.Background()); err != nil { t.Fatal(err) } if err := f.db.Get(&point, "SELECT sampled_at time,cpu,memory,storage,1 samples FROM resource_samples WHERE resource_id='sample-node' ORDER BY sampled_at DESC LIMIT 1"); err != nil { t.Fatal(err) } if point.CPU != nil || point.Memory != nil || point.Storage != nil { t.Fatal("offline node claimed a valid sample") } }