195 lines
6.8 KiB
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")
|
|
}
|
|
}
|