356 lines
14 KiB
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")
|
|
}
|
|
}
|