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

356 lines
14 KiB
Go

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")
}
}