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

195 lines
6.8 KiB
Go

package server
import (
"encoding/json"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func rawAdminSession(t *testing.T, f *fixture, token string) string {
t.Helper()
c := &claims{}
_, err := jwt.ParseWithClaims(token, c, func(*jwt.Token) (any, error) { return []byte(f.server.Config.JWTSecret), nil })
if err != nil {
t.Fatal(err)
}
return c.ID
}
func TestAdminSessionsSelectiveRevocationAndAudit(t *testing.T) {
f := newFixtureWorker(t, false)
owner := f.login("admin")
target := createTestAccount(t, f, owner, "session-reader", "viewer")
first := accountLogin(t, f, "session-reader", testAccountPassword)
second := accountLogin(t, f, "session-reader", testAccountPassword)
raw := rawAdminSession(t, f, first)
key := f.server.administratorSessionKey(raw)
code, out := f.request("GET", "/administrators/"+target+"/sessions", owner, nil, "")
expect(t, 200, code, out)
if out["total"] != float64(2) {
t.Fatal(out)
}
encoded, _ := json.Marshal(out)
if strings.Contains(string(encoded), raw) || strings.Contains(string(encoded), first) {
t.Fatal("credential material disclosed")
}
code, out = f.request("DELETE", "/administrators/"+target+"/sessions/"+key, owner, nil, "")
expect(t, 204, code, out)
code, out = f.request("GET", "/auth/me", first, nil, "")
expect(t, 401, code, out)
code, out = f.request("GET", "/auth/me", second, nil, "")
expect(t, 200, code, out)
code, out = f.request("DELETE", "/administrators/"+target+"/sessions/"+key, owner, nil, "")
expect(t, 404, code, out)
var count int
f.db.Get(&count, "SELECT count(*) FROM admin_session_details WHERE session_id=$1", raw)
if count != 0 {
t.Fatal("metadata not cascaded")
}
var audit string
if err := f.db.Get(&audit, "SELECT target FROM audit_logs WHERE action='revoke_administrator_session'"); err != nil {
t.Fatal(err)
}
if audit != "session-reader:"+key || strings.Contains(audit, raw) {
t.Fatal("wrong audit handle")
}
}
func TestAdminSessionsPermissionsMetadataAndLegacy(t *testing.T) {
f := newFixtureWorker(t, false)
owner := f.login("admin")
var target string
f.db.Get(&target, "SELECT id FROM administrators WHERE username='admin'")
raw := rawAdminSession(t, f, owner)
key := f.server.administratorSessionKey(raw)
path := "/administrators/" + target + "/sessions"
for _, user := range []string{"", "viewer", "operator"} {
token := ""
want := 401
if user != "" {
token = f.login(user)
want = 403
}
for _, req := range []struct{ method, path string }{{"GET", path}, {"DELETE", path + "/" + key}} {
code, out := f.request(req.method, req.path, token, nil, "")
expect(t, want, code, out)
}
}
code, out := f.request("GET", path, owner, nil, "")
expect(t, 200, code, out)
row := out["items"].([]any)[0].(map[string]any)
if row["current"] != true || row["createdAt"] == nil {
t.Fatal(row)
}
code, out = f.request("DELETE", path+"/"+key, owner, nil, "")
expect(t, 409, code, out)
code, out = f.request("DELETE", path+"/"+raw, owner, nil, "")
expect(t, 404, code, out)
code, out = f.request("GET", "/administrators/missing/sessions", owner, nil, "")
expect(t, 404, code, out)
// Pre-upgrade sessions remain valid, with unknown source/time rather than invented metadata.
f.db.MustExec("DELETE FROM admin_session_details WHERE session_id=$1", raw)
code, out = f.request("GET", path, owner, nil, "")
expect(t, 200, code, out)
row = out["items"].([]any)[0].(map[string]any)
if row["createdAt"] != nil || row["clientIp"] != "" || row["userAgent"] != "" {
t.Fatal(row)
}
f.db.MustExec("INSERT INTO admin_sessions VALUES('expired-session',$1,now()-interval '1 second')", target)
code, out = f.request("GET", path, owner, nil, "")
expect(t, 200, code, out)
if out["total"] != float64(1) {
t.Fatal(out)
}
code, out = f.request("DELETE", path+"/"+f.server.administratorSessionKey("expired-session"), owner, nil, "")
expect(t, 404, code, out)
request := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"username":"viewer","password":"Demo@2026"}`))
request.RemoteAddr = "192.0.2.8:42000"
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Forwarded-For", "198.51.100.99")
request.Header.Set("User-Agent", "Client\n"+strings.Repeat("界", 600))
response := httptest.NewRecorder()
f.server.Router.ServeHTTP(response, request)
if response.Code != 200 {
t.Fatal(response.Body.String())
}
var source struct {
IP string `db:"client_ip"`
Agent string `db:"user_agent"`
}
if err := f.db.Get(&source, "SELECT client_ip,user_agent FROM admin_session_details d JOIN admin_sessions x ON x.id=d.session_id JOIN administrators a ON a.id=x.admin_id WHERE a.username='viewer' ORDER BY d.created_at DESC LIMIT 1"); err != nil {
t.Fatal(err)
}
if source.IP != "192.0.2.8" || len([]rune(source.Agent)) != 500 || strings.Contains(source.Agent, "\n") {
t.Fatal("invalid login source")
}
}
func TestAdminSessionsConcurrentRevocationAndActorRecheck(t *testing.T) {
f := newFixtureWorker(t, false)
owner := f.login("admin")
target := createTestAccount(t, f, owner, "concurrent-sessions", "viewer")
token := accountLogin(t, f, "concurrent-sessions", testAccountPassword)
path := "/administrators/" + target + "/sessions/" + f.server.administratorSessionKey(rawAdminSession(t, f, token))
codes := make(chan int, 2)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() { defer wg.Done(); code, _ := f.request("DELETE", path, owner, nil, ""); codes <- code }()
}
wg.Wait()
close(codes)
sum := 0
for code := range codes {
if code != 204 && code != 404 {
t.Fatal(code)
}
sum += code
}
if sum != 608 {
t.Fatal("duplicate successful revocation", sum)
}
var count int
f.db.Get(&count, "SELECT count(*) FROM audit_logs WHERE action='revoke_administrator_session'")
if count != 1 {
t.Fatal(count)
}
// Middleware authenticated before revocation must not authorize a write after waiting on the account lock.
tx := f.db.MustBegin()
defer tx.Rollback()
tx.MustExec("SELECT pg_advisory_xact_lock(7651092028)")
done := make(chan int, 1)
go func() { code, _ := f.request("DELETE", path, owner, nil, ""); done <- code }()
deadline := time.Now().Add(3 * time.Second)
for {
var waiting int
if err := f.db.Get(&waiting, "SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event='advisory' AND pid<>pg_backend_pid()"); err != nil {
t.Fatal(err)
}
if waiting > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("request did not reach account transaction")
}
time.Sleep(10 * time.Millisecond)
}
tx.MustExec("DELETE FROM admin_sessions WHERE id=$1", rawAdminSession(t, f, owner))
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
select {
case code := <-done:
if code != 401 {
t.Fatal(code)
}
case <-time.After(3 * time.Second):
t.Fatal("blocked request")
}
}