196 lines
8.4 KiB
Go
196 lines
8.4 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"vdi-cloud/platform/internal/store"
|
|
)
|
|
|
|
const testAccountPassword = "Account-test-2026"
|
|
|
|
func createTestAccount(t *testing.T, f *fixture, token, username, role string) string {
|
|
t.Helper()
|
|
status, out := f.request("POST", "/administrators", token, map[string]any{"username": username, "displayName": "Account test", "role": role, "password": testAccountPassword}, "")
|
|
expect(t, 201, status, out)
|
|
for _, field := range []string{"password", "passwordHash", "password_hash"} {
|
|
if _, ok := out[field]; ok {
|
|
t.Fatal("credential leaked", field)
|
|
}
|
|
}
|
|
return out["id"].(string)
|
|
}
|
|
func accountLogin(t *testing.T, f *fixture, username, password string) string {
|
|
t.Helper()
|
|
status, out := f.request("POST", "/auth/login", "", map[string]any{"username": username, "password": password}, "")
|
|
expect(t, 200, status, out)
|
|
return out["accessToken"].(string)
|
|
}
|
|
|
|
func TestAdministratorLifecycleAndSessionRevocation(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.login("admin")
|
|
id := createTestAccount(t, f, admin, "maintainer", "operator")
|
|
token := accountLogin(t, f, "maintainer", testAccountPassword)
|
|
status, out := f.request("GET", "/administrators", token, nil, "")
|
|
expect(t, 403, status, out)
|
|
status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Read-only", "role": "viewer", "enabled": true}, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("GET", "/auth/me", token, nil, "")
|
|
expect(t, 401, status, out)
|
|
token = accountLogin(t, f, "maintainer", testAccountPassword)
|
|
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", token, map[string]any{"action": "start"}, "viewer-no-power")
|
|
expect(t, 403, status, out)
|
|
status, out = f.request("POST", "/administrators/"+id+"/password", admin, map[string]any{"password": "Reset-test-2026"}, "")
|
|
expect(t, 204, status, out)
|
|
status, out = f.request("GET", "/auth/me", token, nil, "")
|
|
expect(t, 401, status, out)
|
|
status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "maintainer", "password": testAccountPassword}, "")
|
|
expect(t, 401, status, out)
|
|
token = accountLogin(t, f, "maintainer", "Reset-test-2026")
|
|
status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Disabled", "role": "viewer", "enabled": false}, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("GET", "/auth/me", token, nil, "")
|
|
expect(t, 401, status, out)
|
|
status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "maintainer", "password": "Reset-test-2026"}, "")
|
|
expect(t, 401, status, out)
|
|
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 administrators WHERE id=$1", id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if enabled {
|
|
t.Fatal("restart re-enabled disabled account")
|
|
}
|
|
status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Restored", "role": "operator", "enabled": true}, "")
|
|
expect(t, 200, status, out)
|
|
_ = accountLogin(t, f, "maintainer", "Reset-test-2026")
|
|
var audit []string
|
|
if err := f.db.Select(&audit, "SELECT action||':'||target FROM audit_logs WHERE target='maintainer' AND action LIKE '%administrator%'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(audit) != 5 {
|
|
t.Fatal("account changes not audited", audit)
|
|
}
|
|
status, out = f.request("GET", "/administrators", admin, nil, "")
|
|
expect(t, 200, status, out)
|
|
encoded, _ := json.Marshal(out)
|
|
if strings.Contains(string(encoded), "$2") || strings.Contains(string(encoded), testAccountPassword) {
|
|
t.Fatal("credential material in account listing")
|
|
}
|
|
}
|
|
|
|
func TestOwnPasswordRequiresCurrentPasswordAndInvalidatesAllSessions(t *testing.T) {
|
|
f := newFixture(t)
|
|
token := f.login("viewer")
|
|
other := f.login("viewer")
|
|
status, out := f.request("POST", "/auth/password", token, map[string]any{"currentPassword": "wrong-password", "newPassword": "Updated-test-2026"}, "")
|
|
expect(t, 400, status, out)
|
|
status, out = f.request("GET", "/auth/me", token, nil, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("POST", "/auth/password", token, map[string]any{"currentPassword": "Demo@2026", "newPassword": "Updated-test-2026"}, "")
|
|
expect(t, 204, status, out)
|
|
for _, old := range []string{token, other} {
|
|
status, out = f.request("GET", "/auth/me", old, nil, "")
|
|
expect(t, 401, status, out)
|
|
}
|
|
status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "viewer", "password": "Demo@2026"}, "")
|
|
expect(t, 401, status, out)
|
|
_ = accountLogin(t, f, "viewer", "Updated-test-2026")
|
|
var audit string
|
|
if err := f.db.Get(&audit, "SELECT action FROM audit_logs WHERE action='change_own_password'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAdministratorValidationRBACAndLastOwner(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.login("admin")
|
|
var adminID string
|
|
if err := f.db.Get(&adminID, "SELECT id FROM administrators WHERE username='admin'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, role := range []string{"operator", "viewer"} {
|
|
token := f.login(role)
|
|
for _, r := range []struct{ method, path string }{{"GET", "/administrators"}, {"POST", "/administrators"}, {"PUT", "/administrators/" + adminID}, {"POST", "/administrators/" + adminID + "/password"}} {
|
|
status, out := f.request(r.method, r.path, token, map[string]any{}, "")
|
|
expect(t, 403, status, out)
|
|
}
|
|
}
|
|
for _, password := range []string{"short", strings.Repeat("a", 73), strings.Repeat("密", 25)} {
|
|
status, out := f.request("POST", "/administrators", admin, map[string]any{"username": "invalid-pass", "displayName": "Invalid", "role": "viewer", "password": password}, "")
|
|
expect(t, 400, status, out)
|
|
}
|
|
body := map[string]any{"username": "test-user", "displayName": "Valid", "role": "super_admin", "password": testAccountPassword}
|
|
status, out := f.request("POST", "/administrators", admin, body, "")
|
|
expect(t, 201, status, out)
|
|
status, out = f.request("POST", "/administrators", admin, body, "")
|
|
expect(t, 409, status, out)
|
|
status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self", "role": "viewer", "enabled": true}, "")
|
|
expect(t, 409, status, out)
|
|
status, out = f.request("POST", "/administrators/"+adminID+"/password", admin, map[string]any{"password": testAccountPassword}, "")
|
|
expect(t, 409, status, out)
|
|
status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self name", "role": "super_admin", "enabled": true}, "")
|
|
expect(t, 200, status, out)
|
|
status, out = f.request("GET", "/auth/me", admin, nil, "")
|
|
expect(t, 200, status, out)
|
|
if out["displayName"] != "Self name" {
|
|
t.Fatal(out)
|
|
}
|
|
// The final owner's role and enabled state remain protected independently of UI restrictions.
|
|
if _, err := f.db.Exec("UPDATE administrators SET enabled=false WHERE username='test-user'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self", "role": "super_admin", "enabled": false}, "")
|
|
expect(t, 409, status, out)
|
|
if out["code"] != "last_super_admin" {
|
|
t.Fatal(out)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentAdministratorDisableKeepsOneOwner(t *testing.T) {
|
|
f := newFixture(t)
|
|
admin := f.login("admin")
|
|
otherID := createTestAccount(t, f, admin, "second-owner", "super_admin")
|
|
other := accountLogin(t, f, "second-owner", testAccountPassword)
|
|
var adminID string
|
|
if err := f.db.Get(&adminID, "SELECT id FROM administrators WHERE username='admin'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
start := make(chan struct{})
|
|
results := make(chan int, 2)
|
|
var group sync.WaitGroup
|
|
for _, r := range []struct{ token, target string }{{admin, otherID}, {other, adminID}} {
|
|
group.Add(1)
|
|
go func(token, target string) {
|
|
defer group.Done()
|
|
<-start
|
|
status, _ := f.request("PUT", "/administrators/"+target, token, map[string]any{"displayName": "Concurrent", "role": "super_admin", "enabled": false}, "")
|
|
results <- status
|
|
}(r.token, r.target)
|
|
}
|
|
close(start)
|
|
group.Wait()
|
|
close(results)
|
|
success := 0
|
|
for status := range results {
|
|
if status == 200 {
|
|
success++
|
|
} else if status != 401 && status != 403 {
|
|
t.Fatalf("unexpected losing request status %d", status)
|
|
}
|
|
}
|
|
var count int
|
|
if err := f.db.Get(&count, "SELECT count(*) FROM administrators WHERE enabled AND role='super_admin'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if success != 1 || count != 1 {
|
|
t.Fatal("concurrent writes lost owner", success, count)
|
|
}
|
|
}
|