package admin_test import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "testing/fstest" "github.com/owncord/server/admin" "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/service" ) // newTestModService builds a real ModerationService over the test database so // PATCH-user ban paths exercise the production authorization (BAN_MEMBERS + // role hierarchy) instead of a stub. func newTestModService(database *db.DB) *service.ModerationService { st := database checker := permissions.NewChecker(st) return service.NewModerationService(st, service.NewPermissionService(st, checker)) } // adminSchema is a minimal in-memory schema for admin API tests. var adminSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, color TEXT, permissions INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0 ); INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES (1, 'Owner', '#E74C3C', 2147483647, 100, 0), (2, 'Admin', '#F39C12', 1073741823, 80, 0), (3, 'Member', NULL, 1635, 40, 1); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password TEXT NOT NULL, avatar TEXT, role_id INTEGER NOT NULL DEFAULT 3 REFERENCES roles(id), totp_secret TEXT, status TEXT NOT NULL DEFAULT 'offline', created_at TEXT NOT NULL DEFAULT (datetime('now')), last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, identity_public_key TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, device TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'text', category TEXT, topic TEXT, position INTEGER NOT NULL DEFAULT 0, slow_mode INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, voice_max_video INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, allow INTEGER NOT NULL DEFAULT 0, deny INTEGER NOT NULL DEFAULT 0, UNIQUE(channel_id, role_id) ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id), content TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, timestamp TEXT NOT NULL DEFAULT (datetime('now')), reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, edited_at TEXT ); CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, created_by INTEGER NOT NULL REFERENCES users(id), max_uses INTEGER, use_count INTEGER NOT NULL DEFAULT 0, expires_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), revoked INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id INTEGER NOT NULL DEFAULT 0, action TEXT NOT NULL, target_type TEXT NOT NULL DEFAULT '', target_id INTEGER NOT NULL DEFAULT 0, detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, label TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used_at TEXT, expires_at TEXT, revoked_at TEXT ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); INSERT OR IGNORE INTO settings (key, value) VALUES ('server_name', 'Test Server'), ('motd', 'Hello'); `) // openAdminTestDB opens a fresh in-memory database for admin API tests. func openAdminTestDB(t *testing.T) *db.DB { t.Helper() database, err := db.Open(":memory:") if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { _ = database.Close() }) migrFS := fstest.MapFS{ "001_schema.sql": {Data: adminSchema}, } if err := db.MigrateFS(database, migrFS); err != nil { t.Fatalf("MigrateFS: %v", err) } return database } // createAdminUser creates an Owner-role user and returns a valid bearer token. func createAdminUser(t *testing.T, database *db.DB) string { t.Helper() // Owner role has permissions = 2147483647 (includes ADMINISTRATOR bit 0x40000000) uid, err := database.CreateUser(context.Background(), "adminuser", "$2a$12$placeholder", 1) if err != nil { t.Fatalf("CreateUser admin: %v", err) } token := "test-admin-token-" + t.Name() tokenHash := auth.HashToken(token) if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } return token } // createMemberUser creates a Member-role user and returns a valid bearer token. func createMemberUser(t *testing.T, database *db.DB) string { t.Helper() // Member role (id=3) has limited permissions, not ADMINISTRATOR uid, err := database.CreateUser(context.Background(), "memberuser", "$2a$12$placeholder", 3) if err != nil { t.Fatalf("CreateUser member: %v", err) } token := "test-member-token-" + t.Name() tokenHash := auth.HashToken(token) if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } return token } func doRequest(t *testing.T, handler http.Handler, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() var bodyBytes []byte if body != nil { var err error bodyBytes, err = json.Marshal(body) if err != nil { t.Fatalf("json.Marshal body: %v", err) } } req := httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } if body != nil { req.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // ─── GET /admin/api/stats ───────────────────────────────────────────────────── func TestAdminAPI_Stats_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } var stats map[string]any if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil { t.Fatalf("unmarshal stats: %v", err) } if _, ok := stats["user_count"]; !ok { t.Error("response missing 'user_count'") } if _, ok := stats["message_count"]; !ok { t.Error("response missing 'message_count'") } } func TestAdminAPI_Stats_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) if w.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w.Code) } } func TestAdminAPI_Stats_Forbidden(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createMemberUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) if w.Code != http.StatusForbidden { t.Errorf("status = %d, want 403", w.Code) } } // ─── GET /admin/api/users ───────────────────────────────────────────────────── func TestAdminAPI_ListUsers_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } var users []any if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil { t.Fatalf("unmarshal users: %v", err) } // At least the admin user we created if len(users) < 1 { t.Error("expected at least 1 user in response") } } func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // No query params — should use defaults w := doRequest(t, handler, http.MethodGet, "/users", token, nil) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200", w.Code) } } func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/users", "", nil) if w.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w.Code) } } // ─── PATCH /admin/api/users/{id} ───────────────────────────────────────────── // TestAdminAPI_PatchUser_BanHierarchy locks the W1-4 fix: the admin-auth // perimeter alone no longer authorizes bans — ModerationService's role // hierarchy runs on the live PATCH path, so an admin-panel actor cannot ban // an equal- or higher-ranked user (previously any panel actor could ban the // owner via the raw UPDATE). func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) ownerToken := createAdminUser(t, database) // Owner role (pos 100) // A second owner-rank user: equal position, cannot be banned. peerUID, err := database.CreateUser(context.Background(), "peerowner", "$2a$12$placeholder", 1) if err != nil { t.Fatalf("CreateUser peerowner: %v", err) } w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(peerUID), ownerToken, map[string]any{"banned": true}) if w.Code != http.StatusForbidden { t.Fatalf("equal-rank ban: status = %d, want 403; body: %s", w.Code, w.Body.String()) } if u, _ := database.GetUserByID(context.Background(), peerUID); u.Banned { t.Fatal("equal-rank target must not be banned") } // A lower-positioned role that still holds ADMINISTRATOR (panel access): // its holder must not be able to ban the higher-ranked owner. if _, err := database.ExecContext(context.Background(), `INSERT INTO roles (id, name, permissions, position, is_default) VALUES (9, 'JuniorAdmin', ?, 50, 0)`, permissions.Administrator, ); err != nil { t.Fatalf("inserting junior admin role: %v", err) } juniorUID, err := database.CreateUser(context.Background(), "junioradmin", "$2a$12$placeholder", 9) if err != nil { t.Fatalf("CreateUser junioradmin: %v", err) } juniorToken := "junior-token-" + t.Name() if _, err := database.CreateSession(context.Background(), juniorUID, auth.HashToken(juniorToken), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession junior: %v", err) } ownerUser, err := database.GetUserByUsername(context.Background(), "adminuser") if err != nil || ownerUser == nil { t.Fatalf("GetUserByUsername adminuser: %v", err) } w = doRequest(t, handler, http.MethodPatch, "/users/"+itoa(ownerUser.ID), juniorToken, map[string]any{"banned": true}) if w.Code != http.StatusForbidden { t.Fatalf("junior bans owner: status = %d, want 403; body: %s", w.Code, w.Body.String()) } if u, _ := database.GetUserByID(context.Background(), ownerUser.ID); u.Banned { t.Fatal("owner must not be banned by a lower rank") } // Downward ban still works: junior admin (pos 50) bans a member (pos 40). memberUID, err := database.CreateUser(context.Background(), "banme", "$2a$12$placeholder", 3) if err != nil { t.Fatalf("CreateUser banme: %v", err) } w = doRequest(t, handler, http.MethodPatch, "/users/"+itoa(memberUID), juniorToken, map[string]any{"banned": true, "ban_reason": "spam"}) if w.Code != http.StatusOK { t.Fatalf("junior bans member: status = %d, want 200; body: %s", w.Code, w.Body.String()) } if u, _ := database.GetUserByID(context.Background(), memberUID); !u.Banned { t.Fatal("member should be banned by higher-ranked actor") } } func TestAdminAPI_PatchUser_BanUser(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a target user targetUID, _ := database.CreateUser(context.Background(), "target", "hash", 3) body := map[string]any{ "banned": true, "ban_reason": "spam", } w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } // Verify user is banned in DB user, err := database.GetUserByID(context.Background(), targetUID) if err != nil { t.Fatalf("GetUserByID: %v", err) } if !user.Banned { t.Error("user should be banned after PATCH") } } func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "rolechange", "hash", 3) body := map[string]any{ "role_id": float64(2), } w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } user, _ := database.GetUserByID(context.Background(), targetUID) if user.RoleID != 2 { t.Errorf("RoleID = %d, want 2", user.RoleID) } } func TestAdminAPI_PatchUser_NotFound(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"banned": true} w := doRequest(t, handler, http.MethodPatch, "/users/99999", token, body) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } func TestAdminAPI_PatchUser_InvalidID(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } // ─── DELETE /admin/api/users/{id}/sessions ──────────────────────────────────── func TestAdminAPI_ForceLogout_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "logoutme", "hash", 3) _, _ = database.CreateSession(context.Background(), targetUID, "victim-token-hash", "web", "1.2.3.4") w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil) if w.Code != http.StatusNoContent { t.Errorf("status = %d, want 204", w.Code) } sessions, _ := database.GetUserSessions(context.Background(), targetUID) if len(sessions) != 0 { t.Errorf("expected 0 sessions after force logout, got %d", len(sessions)) } } func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil) if w.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w.Code) } } // ─── GET /admin/api/channels ────────────────────────────────────────────────── func TestAdminAPI_ListChannels_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) _, _ = database.AdminCreateChannel(context.Background(), "general", "text", "", "", 0) w := doRequest(t, handler, http.MethodGet, "/channels", token, nil) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } var channels []any if err := json.Unmarshal(w.Body.Bytes(), &channels); err != nil { t.Fatalf("unmarshal channels: %v", err) } if len(channels) != 1 { t.Errorf("expected 1 channel, got %d", len(channels)) } } // ─── POST /admin/api/channels ───────────────────────────────────────────────── func TestAdminAPI_CreateChannel_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ "name": "new-channel", "type": "text", "category": "General", "topic": "Discussion", "position": float64(1), } w := doRequest(t, handler, http.MethodPost, "/channels", token, body) if w.Code != http.StatusCreated { t.Errorf("status = %d, want 201; body: %s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if _, ok := resp["id"]; !ok { t.Error("response missing 'id'") } } func TestAdminAPI_CreateChannel_MissingName(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ "type": "text", } w := doRequest(t, handler, http.MethodPost, "/channels", token, body) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } // ─── PATCH /admin/api/channels/{id} ────────────────────────────────────────── func TestAdminAPI_UpdateChannel_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "old", "text", "", "", 0) body := map[string]any{ "name": "updated", "topic": "new topic", "slow_mode": float64(10), "position": float64(2), "archived": false, } w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } } func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "x"} w := doRequest(t, handler, http.MethodPatch, "/channels/99999", token, body) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } // ─── DELETE /admin/api/channels/{id} ───────────────────────────────────────── func TestAdminAPI_DeleteChannel_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "del-me", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) if w.Code != http.StatusNoContent { t.Errorf("status = %d, want 204", w.Code) } } func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } // ─── GET /admin/api/audit-log ───────────────────────────────────────────────── func TestAdminAPI_AuditLog_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) uid, _ := database.CreateUser(context.Background(), "actor", "hash", 1) _ = database.LogAudit(context.Background(), uid, "TEST_ACTION", "user", uid, "detail") w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=10&offset=0", token, nil) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } var entries []any if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil { t.Fatalf("unmarshal: %v", err) } if len(entries) != 1 { t.Errorf("expected 1 entry, got %d", len(entries)) } } func TestAdminAPI_AuditLog_Empty(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200", w.Code) } var entries []any _ = json.Unmarshal(w.Body.Bytes(), &entries) if len(entries) != 0 { t.Errorf("expected 0 entries, got %d", len(entries)) } } // ─── GET /admin/api/settings ────────────────────────────────────────────────── func TestAdminAPI_GetSettings_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/settings", token, nil) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } var settings map[string]string if err := json.Unmarshal(w.Body.Bytes(), &settings); err != nil { t.Fatalf("unmarshal settings: %v", err) } if _, ok := settings["server_name"]; !ok { t.Error("response missing 'server_name'") } } // ─── PATCH /admin/api/settings ──────────────────────────────────────────────── func TestAdminAPI_PatchSettings_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ "server_name": "Updated Server", "motd": "New MOTD", } w := doRequest(t, handler, http.MethodPatch, "/settings", token, body) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } // Verify the change was persisted val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting: %v", err) } if val != "Updated Server" { t.Errorf("server_name = %q, want 'Updated Server'", val) } } func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json"))) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } } // ─── POST /admin/api/backup ─────────────────────────────────────────────────── func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Admin (role 2) can authenticate but is not Owner (role 1, position 100) adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) token := "admin-only-token" _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) // Should be forbidden — not Owner role if w.Code != http.StatusForbidden { t.Errorf("status = %d, want 403", w.Code) } } func TestAdminAPI_Backup_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) if w.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w.Code) } } // ─── Task 0.3: actor stored in context ─────────────────────────────────────── // TestAdminAPI_ActorFromContext verifies that after auth middleware runs, the // user ID surfaced by audit log entries comes from the context-stored user (not // a redundant DB lookup). We exercise this through the PATCH /users/{id} path // which logs an audit entry containing the actor_id. func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a target user to act on. targetUID, _ := database.CreateUser(context.Background(), "ctxtarget", "hash", 3) body := map[string]any{"banned": true, "ban_reason": "context test"} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } // The audit log should have a non-zero actor_id showing the actor was // resolved (not 0, which would indicate a failed context lookup). entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog: %v", err) } if len(entries) == 0 { t.Fatal("expected at least 1 audit entry") } // All entries should have a non-zero actor_id. for _, e := range entries { if e.ActorID == 0 { t.Errorf("audit entry actor_id = 0, expected the admin user's ID (actor stored from context)") } } } // TestAdminAPI_ActorFromContext_ForceLogout exercises actorFromContext via the // DELETE /users/{id}/sessions path. func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "logoutctx", "hash", 3) _, _ = database.CreateSession(context.Background(), targetUID, "victim-hash-ctx", "web", "1.2.3.4") w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil) if w.Code != http.StatusNoContent { t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) } entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog: %v", err) } if len(entries) == 0 { t.Fatal("expected at least 1 audit entry") } for _, e := range entries { if e.ActorID == 0 { t.Errorf("audit entry actor_id = 0, expected non-zero actor from context") } } } // ─── Task 0.6: Settings key whitelist ──────────────────────────────────────── // TestAdminAPI_PatchSettings_RejectsUnknownKey verifies that an unknown key // returns 400 without writing anything to the database. func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ "unknown_key": "should be rejected", } w := doRequest(t, handler, http.MethodPatch, "/settings", token, body) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) } var resp map[string]string if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error response: %v", err) } // The error message must name the offending key so the caller knows what to fix. if msg, ok := resp["message"]; !ok || msg == "" { t.Error("response should include a non-empty 'message' field") } } // TestAdminAPI_PatchSettings_RejectsMixedKeys verifies that a payload // containing both valid and invalid keys is rejected entirely (no partial write). func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ "server_name": "valid", "injected_key": "should block the whole request", } w := doRequest(t, handler, http.MethodPatch, "/settings", token, body) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) } // The valid key must NOT have been written because the request was rejected. val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting: %v", err) } if val == "valid" { t.Error("server_name was updated despite invalid key in payload — partial write occurred") } } // TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys iterates over every key // in the whitelist and confirms each one is individually accepted. func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) { whitelistedKeys := []string{ "server_name", "server_icon", "motd", "max_upload_bytes", "voice_quality", "require_2fa", "registration_open", "backup_schedule", "backup_retention", } // Boolean-typed settings require valid boolean values; others accept any string. booleanKeys := map[string]bool{"require_2fa": true, "registration_open": true} for _, key := range whitelistedKeys { t.Run(key, func(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) value := "testvalue" if booleanKeys[key] { value = "0" } body := map[string]string{key: value} w := doRequest(t, handler, http.MethodPatch, "/settings", token, body) if w.Code != http.StatusOK { t.Errorf("key %q: status = %d, want 200; body: %s", key, w.Code, w.Body.String()) } }) } } // TestAdminAPI_PatchSettings_EmptyPayloadIsOK verifies that an empty map // (no-op update) is accepted and returns the current settings. func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{} w := doRequest(t, handler, http.MethodPatch, "/settings", token, body) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } } func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ "registration_open": "false", "require_2fa": "true", } w := doRequest(t, handler, http.MethodPatch, "/settings", token, body) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) } } func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrationClosed(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil { t.Fatalf("enroll admin user: %v", err) } body := map[string]string{ "registration_open": "false", "require_2fa": "true", } w := doRequest(t, handler, http.MethodPatch, "/settings", token, body) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } } func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ "require_2fa": "banana", } w := doRequest(t, handler, http.MethodPatch, "/settings", token, body) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) } } // ─── Fix 2.1: Sensitive field redaction ────────────────────────────────────── // TestAdminAPI_ListUsers_NoPasswordHash verifies that GET /users does not // expose the PasswordHash field in any returned user object. func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a second user so the list is non-trivial. _, _ = database.CreateUser(context.Background(), "plainuser", "supersecretbcrypthash", 3) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } body := w.Body.String() // The raw bcrypt hash must never appear in the response. if strings.Contains(body, "supersecretbcrypthash") { t.Error("GET /users response contains PasswordHash — sensitive field leaked") } // The JSON key itself must also be absent. if strings.Contains(body, "password_hash") || strings.Contains(body, "PasswordHash") { t.Error("GET /users response contains password_hash key — sensitive field leaked") } } // TestAdminAPI_ListUsers_NoTOTPSecret verifies that GET /users does not // expose the TOTPSecret field. func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } body := w.Body.String() if strings.Contains(body, "totp_secret") || strings.Contains(body, "TOTPSecret") { t.Error("GET /users response contains totp_secret key — sensitive field leaked") } } // TestAdminAPI_ListUsers_PublicFieldsPresent verifies that safe public fields // are still present after the sensitive-field removal. func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } var users []map[string]any if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil { t.Fatalf("unmarshal: %v", err) } if len(users) == 0 { t.Fatal("expected at least one user") } u := users[0] for _, field := range []string{"id", "username", "status", "role_id", "created_at", "banned", "role_name"} { if _, ok := u[field]; !ok { t.Errorf("GET /users response user object missing expected field %q", field) } } } // TestAdminAPI_PatchUser_NoPasswordHash verifies that PATCH /users/{id} does // not expose PasswordHash in the returned user object. func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "patchvictim", "topsecretbcrypt", 3) body := map[string]any{ "banned": true, "ban_reason": "test", } w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } respBody := w.Body.String() if strings.Contains(respBody, "topsecretbcrypt") { t.Error("PATCH /users/{id} response contains PasswordHash — sensitive field leaked") } if strings.Contains(respBody, "password_hash") || strings.Contains(respBody, "PasswordHash") { t.Error("PATCH /users/{id} response contains password_hash key — sensitive field leaked") } } // TestAdminAPI_PatchUser_NoTOTPSecret verifies that PATCH /users/{id} does // not expose TOTPSecret in the returned user object. func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser(context.Background(), "patchtotp", "hash", 3) w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{ "banned": false, }) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } respBody := w.Body.String() if strings.Contains(respBody, "totp_secret") || strings.Contains(respBody, "TOTPSecret") { t.Error("PATCH /users/{id} response contains totp_secret — sensitive field leaked") } } // ─── 4.1: Channel CRUD broadcast tests ─────────────────────────────────────── // mockHub records which broadcast methods were called and with what arguments. type mockHub struct { restartCalls []restartCall channelCreates []*db.Channel channelUpdates []*db.Channel channelDeleteIDs []int64 memberBanIDs []int64 memberUpdates []memberUpdateCall visibilityRefreshes []*db.Channel clientCount int } type memberUpdateCall struct { userID int64 roleName string } type restartCall struct { reason string delaySeconds int } func (m *mockHub) BroadcastServerRestart(reason string, delaySeconds int) { m.restartCalls = append(m.restartCalls, restartCall{reason, delaySeconds}) } func (m *mockHub) BroadcastChannelCreate(ch *db.Channel) { m.channelCreates = append(m.channelCreates, ch) } func (m *mockHub) BroadcastChannelUpdate(ch *db.Channel) { m.channelUpdates = append(m.channelUpdates, ch) } func (m *mockHub) BroadcastChannelDelete(channelID int64) { m.channelDeleteIDs = append(m.channelDeleteIDs, channelID) } func (m *mockHub) BroadcastMemberBan(userID int64) { m.memberBanIDs = append(m.memberBanIDs, userID) } func (m *mockHub) BroadcastMemberUpdate(userID int64, roleName string) { m.memberUpdates = append(m.memberUpdates, memberUpdateCall{userID, roleName}) } func (m *mockHub) RefreshChannelVisibility(ch *db.Channel) { m.visibilityRefreshes = append(m.visibilityRefreshes, ch) } func (m *mockHub) ClientCount() int { return m.clientCount } func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ "name": "broadcast-test", "type": "text", } w := doRequest(t, handler, http.MethodPost, "/channels", token, body) if w.Code != http.StatusCreated { t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String()) } if len(hub.channelCreates) != 1 { t.Fatalf("BroadcastChannelCreate called %d times, want 1", len(hub.channelCreates)) } if hub.channelCreates[0].Name != "broadcast-test" { t.Errorf("broadcast channel name = %q, want broadcast-test", hub.channelCreates[0].Name) } } func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) // nil hub: handler must not panic handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "safe-channel", "type": "text"} w := doRequest(t, handler, http.MethodPost, "/channels", token, body) if w.Code != http.StatusCreated { t.Errorf("status = %d, want 201", w.Code) } } func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "before", "text", "", "", 0) body := map[string]any{"name": "after"} w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } if len(hub.channelUpdates) != 1 { t.Fatalf("BroadcastChannelUpdate called %d times, want 1", len(hub.channelUpdates)) } if hub.channelUpdates[0].Name != "after" { t.Errorf("broadcast channel name = %q, want after", hub.channelUpdates[0].Name) } } func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "patchme", "text", "", "", 0) body := map[string]any{"name": "patched"} w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200", w.Code) } } func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "delete-me", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) if w.Code != http.StatusNoContent { t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) } if len(hub.channelDeleteIDs) != 1 { t.Fatalf("BroadcastChannelDelete called %d times, want 1", len(hub.channelDeleteIDs)) } if hub.channelDeleteIDs[0] != chID { t.Errorf("broadcast channel id = %d, want %d", hub.channelDeleteIDs[0], chID) } } func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel(context.Background(), "del-no-hub", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) if w.Code != http.StatusNoContent { t.Errorf("status = %d, want 204", w.Code) } } // ─── API tokens: /admin/api/tokens ─────────────────────────────────────────── func TestAdminAPI_CreateAPIToken_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Owner role w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "ci-bot"}) if w.Code != http.StatusCreated { t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } raw, _ := resp["token"].(string) if raw == "" { t.Fatal("response missing raw token") } // The minted token must actually authenticate as the owner it was bound to. user, _, _, err := auth.ResolveTokenHash(context.Background(), database, auth.HashToken(raw)) if err != nil || user == nil { t.Fatalf("minted token does not resolve: user=%v err=%v", user, err) } // And it must be listed, without any hash leaking. tokens, _ := database.ListAPITokens(context.Background()) if len(tokens) != 1 || tokens[0].Label != "ci-bot" { t.Fatalf("expected 1 token labelled ci-bot, got %+v", tokens) } } func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": " "}) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) } } func TestAdminAPI_ListAPITokens_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) hash := auth.HashToken("raw-secret-value") if _, err := database.CreateAPIToken(context.Background(), 1, hash, "seeded", nil); err != nil { t.Fatalf("CreateAPIToken: %v", err) } w := doRequest(t, handler, http.MethodGet, "/tokens", token, nil) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } if strings.Contains(w.Body.String(), hash) { t.Error("GET /tokens leaked the token hash") } var tokens []map[string]any if err := json.Unmarshal(w.Body.Bytes(), &tokens); err != nil { t.Fatalf("unmarshal: %v", err) } if len(tokens) != 1 { t.Fatalf("expected 1 token, got %d", len(tokens)) } if _, ok := tokens[0]["created_at"]; !ok { t.Error("token row missing snake_case 'created_at' field") } } func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) hash := auth.HashToken("revoke-me") id, err := database.CreateAPIToken(context.Background(), 1, hash, "doomed", nil) if err != nil { t.Fatalf("CreateAPIToken: %v", err) } w := doRequest(t, handler, http.MethodDelete, "/tokens/"+itoa(id), token, nil) if w.Code != http.StatusNoContent { t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) } // A revoked token must no longer authenticate. active, _ := database.GetActiveAPIToken(context.Background(), hash) if active != nil { t.Error("token still active after revoke") } } func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/tokens/99999", token, nil) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } // TestAdminAPI_Tokens_RequiresOwner locks the Owner gate: a non-Owner admin can // authenticate to /admin/api but must not mint API tokens (the credential that // survives password change + bulk logout). func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) // Admin, not Owner token := "admin-only-token" _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "nope"}) if w.Code != http.StatusForbidden { t.Errorf("status = %d, want 403; body: %s", w.Code, w.Body.String()) } } func TestAdminAPI_Tokens_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/tokens", "", nil) if w.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w.Code) } } // ─── helpers ───────────────────────────────────────────────────────────────── // itoa converts an int64 to a string for use in URL paths. func itoa(n int64) string { return fmt.Sprint(n) }