mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(voice): 4 defect(s) (OC-0008, OC-0009, OC-0042, OC-0080) Guard LiveKit session state against supersession: bump the camera/screen generation in leaveVoice and teardownForReconnect so an in-flight enable discards its track, bail out of restoreLocalVoiceState when a newer room claimed _room mid-await, and recheck isStateConnected in the auto-reconnect tail. * fix(ws): 1 defect(s) (OC-0019) * fix(db): 1 defect(s) (OC-0023) * fix(ws): 1 defect(s) (OC-0029) * fix(ws): 1 defect(s) (OC-0032) * fix(voice): 1 defect(s) (OC-0034) * fix(admin): 1 defect(s) (OC-0035) * fix(service): 2 defect(s) (OC-0036, OC-0128) * fix(voice): 2 defect(s) (OC-0038, OC-0065) OC-0038: the LiveKit participant_left webhook cleared the leaver's own client voice state before broadcasting voice_leave, so the broadcast audience (READ_MESSAGES holders union still-in-the-room participants) could no longer see them. Voice membership is gated on CONNECT_VOICE alone, so a participant without READ_MESSAGES never learned the server had torn down their call. Extracted finishVoiceLeave's audience logic into broadcastVoiceEventWithLeaver and used it on the webhook path. OC-0065: handleWebhookParticipantJoined OR'd a GetVoiceState read error into the same branch as "no matching row", so a transient DB failure ejected a legitimate participant from the SFU mid-call. Now the read error is logged and the check skipped, matching sweepStaleVoiceStates. * fix(client): 1 defect(s) (OC-0041) * fix(client): 1 defect(s) (OC-0043) * fix(client): 1 defect(s) (OC-0046) * fix(client): 1 defect(s) (OC-0047) * fix(client): 1 defect(s) (OC-0049) * fix(client): 1 defect(s) (OC-0108) * fix(client): 2 defect(s) (OC-0111, OC-0143) OC-0111: retry a presence_update dropped by the 1-per-10s limiter once the window reopens, so auto-idle's return-to-online does not leave the server and every other client stuck on idle. OC-0143: pass apiConfig.host to the DM profile sidebar so per-user notes are scoped per server, matching channel mutes, the NSFW gate and volume. * test(ws): align aborted-switch test with OC-0034 no-resurrect behavior The fix agent rewrote this pre-existing test (it locked the buggy restore path) but the prove agent left it out of c67d25ed; committed state alone failed go test ./ws/ without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1577 lines
59 KiB
Go
1577 lines
59 KiB
Go
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))
|
|
}
|
|
|
|
// newTestRoleService builds a real RoleService over the test database so the
|
|
// role routes exercise the production authorization (MANAGE_ROLES + hierarchy)
|
|
// instead of a stub.
|
|
func newTestRoleService(database *db.DB) *service.RoleService {
|
|
st := database
|
|
checker := permissions.NewChecker(st)
|
|
return service.NewRoleService(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
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name_nocase ON roles(name COLLATE NOCASE);
|
|
|
|
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,
|
|
display_name TEXT,
|
|
about TEXT,
|
|
custom_status 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,
|
|
nsfw INTEGER NOT NULL DEFAULT 0,
|
|
is_group 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 channel_user_overrides (
|
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
allow INTEGER NOT NULL DEFAULT 0,
|
|
deny INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (channel_id, user_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,
|
|
mentions_everyone INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS message_mentions (
|
|
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (message_id, mentioned_user_id)
|
|
);
|
|
|
|
|
|
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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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)
|
|
}
|
|
}
|
|
|
|
// Deleting a channel must evict its voice participants BEFORE the DB row goes
|
|
// away: the voice_states FK cascade wipes the rows with the channel, after
|
|
// which neither CleanupVoiceForChannel nor the stale sweeper can see who was
|
|
// in the room — participants would keep their client voice state, voice-topic
|
|
// subscription, and LiveKit session forever, with no voice_leave broadcast.
|
|
func TestAdminAPI_DeleteChannel_CleansVoiceBeforeDBDelete(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
hub := &mockHub{}
|
|
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
|
token := createAdminUser(t, database)
|
|
|
|
// The minimal admin schema has no voice_states; create it with the real
|
|
// FK cascade — the cascade IS the hazard: it wipes the rows the cleanup
|
|
// needs if the delete runs first.
|
|
if _, err := database.ExecContext(context.Background(), `
|
|
CREATE TABLE voice_states (
|
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
muted INTEGER NOT NULL DEFAULT 0,
|
|
deafened INTEGER NOT NULL DEFAULT 0,
|
|
speaking INTEGER NOT NULL DEFAULT 0,
|
|
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
camera INTEGER NOT NULL DEFAULT 0,
|
|
screenshare INTEGER NOT NULL DEFAULT 0,
|
|
server_muted INTEGER NOT NULL DEFAULT 0,
|
|
server_deafened INTEGER NOT NULL DEFAULT 0
|
|
)`); err != nil {
|
|
t.Fatalf("create voice_states: %v", err)
|
|
}
|
|
|
|
chID, _ := database.AdminCreateChannel(context.Background(), "del-voice", "voice", "", "", 0)
|
|
uid, _ := database.CreateUser(context.Background(), "del-voice-user", "hash", 1)
|
|
if err := database.JoinVoiceChannel(context.Background(), uid, chID); err != nil {
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
}
|
|
|
|
rowsAtCleanup := -1
|
|
hub.onVoiceCleanup = func(channelID int64) {
|
|
states, err := database.GetChannelVoiceStates(context.Background(), channelID)
|
|
if err != nil {
|
|
t.Errorf("GetChannelVoiceStates during cleanup: %v", err)
|
|
}
|
|
rowsAtCleanup = len(states)
|
|
}
|
|
|
|
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.voiceCleanupIDs) != 1 || hub.voiceCleanupIDs[0] != chID {
|
|
t.Fatalf("CleanupVoiceForChannel calls = %v, want exactly [%d]", hub.voiceCleanupIDs, chID)
|
|
}
|
|
if rowsAtCleanup != 1 {
|
|
t.Errorf("voice_states rows visible at cleanup time = %d, want 1 (cleanup must run before the delete cascade)", rowsAtCleanup)
|
|
}
|
|
}
|
|
|
|
// A voice_join racing the delete window must be refused, not silently create
|
|
// a voice_states row the FK cascade then wipes out from under it (OC-0035):
|
|
// CleanupVoiceForChannel snapshots participants ONCE, up front, so a join
|
|
// that lands after that snapshot but before AdminDeleteChannel's cascade
|
|
// leaves the joiner's hub-side voice state and LiveKit session orphaned with
|
|
// nothing left to clean it up. handleDeleteChannel must close that window the
|
|
// same way the archive path does (handlePatchChannel): persist archived=true
|
|
// BEFORE evicting current participants, so voice_join's archived gate
|
|
// (ws/voice_join.go) refuses any concurrent join that reads the channel row
|
|
// during cleanup.
|
|
func TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
hub := &mockHub{}
|
|
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
|
token := createAdminUser(t, database)
|
|
|
|
chID, _ := database.AdminCreateChannel(context.Background(), "del-race", "voice", "", "", 0)
|
|
|
|
archivedAtCleanup := false
|
|
hub.onVoiceCleanup = func(channelID int64) {
|
|
ch, err := database.GetChannel(context.Background(), channelID)
|
|
if err != nil || ch == nil {
|
|
t.Fatalf("GetChannel during cleanup: %v", err)
|
|
}
|
|
archivedAtCleanup = ch.Archived
|
|
}
|
|
|
|
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 !archivedAtCleanup {
|
|
t.Errorf("channel.Archived at CleanupVoiceForChannel time = false, want true — a concurrent voice_join would not be refused by the archived gate")
|
|
}
|
|
}
|
|
|
|
func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
|
|
database := openAdminTestDB(t)
|
|
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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
|
|
// allVisibilityRefreshes counts RefreshAllChannelVisibility calls — the
|
|
// role-edit equivalent of visibilityRefreshes.
|
|
allVisibilityRefreshes int
|
|
rolesUpdates [][]*db.Role
|
|
clientCount int
|
|
voiceCleanupIDs []int64
|
|
// onVoiceCleanup lets a test observe DB state at cleanup time (ordering
|
|
// vs. the channel-delete cascade).
|
|
onVoiceCleanup func(channelID int64)
|
|
}
|
|
|
|
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) CleanupVoiceForChannel(channelID int64) {
|
|
m.voiceCleanupIDs = append(m.voiceCleanupIDs, channelID)
|
|
if m.onVoiceCleanup != nil {
|
|
m.onVoiceCleanup(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) RefreshAllChannelVisibility() {
|
|
m.allVisibilityRefreshes++
|
|
}
|
|
|
|
func (m *mockHub) BroadcastRolesUpdate(roles []*db.Role) {
|
|
m.rolesUpdates = append(m.rolesUpdates, roles)
|
|
}
|
|
|
|
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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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), newTestRoleService(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)
|
|
}
|