Author SHA1 Message Date
Claude 2f83d13297 chore(b3-7): satisfy the lint gate — split the message generator, reasoned nolints, modernize idioms
golangci flagged eleven issues, all in profile_alpha.go. funlen/cyclop:
alphaChannelFor, alphaAuthorFor and the sqlBatch chunk machinery move out
of alphaInsertMessages — pure code motion, proven by the regenerated
snapshot being byte-identical to the committed one. gosec gets reasons,
not exemptions by silence: the public dev-profile hash and math/rand are
this profile's documented contract, and both concatenations build only
"(?,…)" placeholder groups. strings.SplitSeq and range-over-int adopted;
alphaVoiceSessions now surfaces in the summary output, where its
no-at-rest-rows interpretation is worth stating at runtime anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:59:12 +00:00
Claude a88d1554be fix(b3-7): inventory row for the profile's db import; join-aware, chronological, uploader-attributed dataset
The Server Build & Test failure was the db-import-boundary rule:
cmd/seed/profile_alpha.go had no DBImportAllow row. Added (boundary — the
profile writes through the handle main.go owns) and the dbinventory table
in server-boundaries.md repasted from the tool.

Codex findings on the dataset, all verified and fixed with the snapshot
regenerated (byte-identity holds; size unchanged at 3.2MB):
- authors before join: alphaJoinTimes is computed once and monotonic in
  the user id, so eligibility at time t is the prefix 1..N; channel
  authors draw from it and DM pairs carry traffic only from their ready
  time (first twelve pairs are all-pre-window so DM traffic exists from
  the first hour; DM channels are created at ready, the
  create-on-first-contact shape).
- non-chronological ids: per-bucket second offsets are sorted before ids
  are assigned; ascending ids now follow ascending timestamps.
- NULL uploader: attachments record uploader_id = the message's author.
- scrub over-claim (P1): scrub.sql and the README now state precisely
  that message content and channel names are deliberately out of scope —
  synthetic here, a judgement call on a real database — so a donated
  production database is not shareable after this script alone.

The canary pins all four as invariants a live database cannot violate:
zero authored-before-join rows, zero adjacent-id timestamp inversions,
zero uploaderless attachments, zero DM messages predating a member.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:41:13 +00:00
J3vb 057cb19eda Merge branch 'dev' into feat/b3-7-alpha-dataset 2026-08-31 07:26:12 +02:00
Claude 0dd3e86bc0 feat(b3-7): alpha-shaped dataset — deterministic seed profile and the v1.2.0-alpha.4 snapshot
go run ./cmd/seed -confirm-dev -profile alpha fills an empty database with
the plan's dataset: 100 users (1/2/5/92 across the four roles), 12 channels
(10 text + 2 voice; 3 role-override, 2 user-override, 1 archived), 20,000
messages over 30 simulated days on a diurnal curve (exactly 15% in DMs
across 40 pairs), 300 attachment rows (60/10/10/20%, 10KB-5MB), 500
reactions, 30 invites (10 revoked), one disabled plugin row. Deterministic
by construction — fixed seed, fixed clock, constant bcrypt hash, explicit
ids and timestamps, VACUUM INTO as the canonical bytes — and
TestAlphaProfileByteIdentical holds the property (two full runs compared
byte for byte; a schema_versions wall-clock leak was the one leak found,
now pinned by the scrub). Two constants deliberately leave no rows and say
why in the package comment: voice sessions are LiveKit-ephemeral, and the
replay log is empty exactly as on a server restarted for an upgrade.

The committed snapshot (3.2MB, under the 5MB LFS line) is the scrubbed
VACUUM of that profile at the alpha.4 migration set - the schema has not
moved since the tag, so it is a true alpha.4 artifact. scrub.sql beside it
also anonymises a real donated database. db/alpha_snapshot_test.go is the
standing canary: provenance (31 applied migrations), HEAD migrations apply
cleanly, and every promised row count checks out, FTS included. Consumers
(B4 HP-4, B6 upgrade rehearsal, B10 in-place upgrade) are named in the
snapshot README and docs/deployment.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:14:13 +00:00
121 changed files with 2790 additions and 3802 deletions
+1 -23
View File
@@ -1,5 +1,5 @@
{
"nextId": 380,
"nextId": 379,
"findings": [
{
"id": "OC-0001",
@@ -8565,28 +8565,6 @@
"test": "Server/api/auth_characterization_test.go, Server/auth/totp_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0379",
"title": "ownerOnlyMiddleware still re-read the role adminAuthMiddleware had already resolved — OC-0345's fix kept the redundant query and the 503 branch that existed only to serve it",
"file": "Server/admin/middleware.go",
"line": 130,
"severity": "low",
"why": "OC-0345's title named two defects: the redundant GetRoleByID and the transient-fault-as-403 collapse. Its fix (PR #1454) repaired only the error mapping — deliberately, per its own suggestedFix, to avoid touching the two tests that injected only adminUserKey — so the owner gate still issued a second role read on every owner-only request and carried a private 503 path whose only job was that read's failures. The register's closure evidence for OC-0345 ('Reuse the authenticated context…') was half-met, and the function's doc comment claimed to avoid the redundant query it performed.",
"repro": "Verified at dev 7abdd941 by the 2026-08-31 post-merge audit: middleware.go:130 ran database.GetRoleByID inside ownerOnlyMiddleware while adminAuthMiddleware had stored the same principal's *db.Role under adminRoleKey (:90) and requirePerm (:104) already consumed it query-free. The nine owner-only routes in Server/admin/api.go paid the extra read; a roles-table fault on it produced the gate's own 503 although the perimeter had just proven the database healthy on the same request.",
"evidence": "RED first: TestOwnerOnlyMiddleware_NoSecondRoleLookup (role in context, roles table renamed away) failed 503 against the old middleware — the second lookup, observed. GREEN after: the gate consumes adminRoleKey (missing role fails closed as 401, exactly requirePerm's contract; position below Owner stays 403), the query and its 503 branch are deleted, and the signature drops *db.DB at all nine call sites, so reintroducing a lookup is a compile-visible change. Revert-proof: restoring the old middleware body fails the build at api.go:150.",
"status": "fixed",
"found": "2026-08-31",
"hunt": "postmerge-audit-2026-08-31",
"lens": "audit",
"confidence": "high",
"finder": "claude",
"fixed": "2026-08-31",
"fix": {
"commit": "bcdc0ef3",
"test": "Server/admin/middleware_and_spawn_test.go",
"revertProof": "pass"
}
}
]
}
+2 -2
View File
@@ -23,11 +23,11 @@ var staticFiles embed.FS
//
// /api/* — admin REST API (all require a moderation permission; see NewAdminAPI)
// /* — embedded static files (SPA; index.html for unknown paths)
func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, settings *service.SettingsService, opts ...SetupOptions) http.Handler {
func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler {
r := chi.NewRouter()
// Admin REST API mounted at /api
r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, roles, settings, opts...))
r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, roles, opts...))
// Static files — serve from the "static" sub-tree of the embedded FS.
// The //go:embed static directive in this package embeds as "static/…",
+10 -10
View File
@@ -20,7 +20,7 @@ import (
// http.Handler with all dependencies wired.
func TestNewHandler_ReturnsNonNilHandler(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
if h == nil {
t.Fatal("NewHandler returned nil handler")
}
@@ -30,7 +30,7 @@ func TestNewHandler_ReturnsNonNilHandler(t *testing.T) {
// responds with 200 and HTML content (the embedded admin SPA).
func TestNewHandler_ServesStaticRoot(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
@@ -61,7 +61,7 @@ func TestNewHandler_ServesStaticRoot(t *testing.T) {
// Content-Security-Policy header allowing inline scripts and styles.
func TestNewHandler_SetsCSPOnRoot(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
@@ -77,7 +77,7 @@ func TestNewHandler_SetsCSPOnRoot(t *testing.T) {
// through the NewHandler-returned handler (setup/status endpoint is unauthenticated).
func TestNewHandler_APIRoutesMounted(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
req := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil)
w := httptest.NewRecorder()
@@ -93,7 +93,7 @@ func TestNewHandler_APIRoutesMounted(t *testing.T) {
// /api require a valid token.
func TestNewHandler_AuthProtectedRoute(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// /api/stats requires authentication
req := httptest.NewRequest(http.MethodGet, "/api/stats", nil)
@@ -110,7 +110,7 @@ func TestNewHandler_AuthProtectedRoute(t *testing.T) {
func TestNewHandler_WithUpdater(t *testing.T) {
database := openAdminTestDB(t)
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
if h == nil {
t.Fatal("NewHandler with updater returned nil handler")
}
@@ -122,7 +122,7 @@ func TestNewHandler_WithUpdater(t *testing.T) {
// (position == 100) can reach backup endpoints.
func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// createAdminUser creates an Owner-role user (role_id=1, position=100)
ownerToken := createAdminUser(t, database)
@@ -157,7 +157,7 @@ func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) {
// (position < 100) cannot reach owner-only endpoints.
func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Create admin user (role_id=2, position=80)
adminUID, _ := database.CreateUser(context.Background(), "middlewareadmin", "hash", 2)
@@ -175,7 +175,7 @@ func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) {
// reach owner-only endpoints.
func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
memberToken := createMemberUser(t, database)
@@ -192,7 +192,7 @@ func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) {
// rejected before reaching ownerOnlyMiddleware.
func TestOwnerOnlyMiddleware_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
+12 -12
View File
@@ -65,7 +65,7 @@ func startSetupLimiterReap(rl *auth.RateLimiter) {
// The optional trailing SetupOptions enables the first-run wizard's
// config.yaml write-back and restart; without it the setup endpoints keep
// their legacy account-only behaviour (the case in most tests).
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, settings *service.SettingsService, opts ...SetupOptions) http.Handler {
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler {
r := chi.NewRouter()
var setupOpts SetupOptions
@@ -147,36 +147,36 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
// API tokens — Owner-only. Minting a network-reachable, revocation-
// surviving bearer credential is gated like backups/updates.
r.Get("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleListAPITokens(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleListAPITokens(database)).ServeHTTP(w, req)
}))
r.Post("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleCreateAPIToken(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleCreateAPIToken(database)).ServeHTTP(w, req)
}))
r.Delete("/tokens/{id}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleRevokeAPIToken(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleRevokeAPIToken(database)).ServeHTTP(w, req)
}))
r.Group(func(r chi.Router) {
r.Use(requirePerm(permissions.ManageServer))
r.Get("/settings", handleGetSettings(settings))
r.Patch("/settings", handlePatchSettings(settings))
r.Get("/settings", handleGetSettings(database))
r.Patch("/settings", handlePatchSettings(database))
})
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleBackup(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
}))
r.Get("/backups", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleListBackups()).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleListBackups()).ServeHTTP(w, req)
}))
r.Delete("/backups/{name}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleDeleteBackup(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleDeleteBackup(database)).ServeHTTP(w, req)
}))
r.Post("/backups/{name}/restore", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleRestoreBackup(database, hub)).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleRestoreBackup(database, hub)).ServeHTTP(w, req)
}))
r.Get("/updates", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleCheckUpdate(u)).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleCheckUpdate(u)).ServeHTTP(w, req)
}))
r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
}))
})
+29 -29
View File
@@ -21,7 +21,7 @@ import (
// their own account via the admin panel.
func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// The admin user created by createAdminUser has id=1. We try to patch id=1.
@@ -37,7 +37,7 @@ func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) {
// banned user unbans them and returns 200.
func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Create and ban a target user first.
@@ -62,7 +62,7 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) {
// expiry so the ban lapses on its own.
func TestAdminAPI_PatchUser_TempBan(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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(), "tempbanme", "hash", 3)
@@ -86,7 +86,7 @@ func TestAdminAPI_PatchUser_TempBan(t *testing.T) {
// TestAdminAPI_PatchUser_TempBanOutOfRange verifies duration bounds are enforced.
func TestAdminAPI_PatchUser_TempBanOutOfRange(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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(), "toolongban", "hash", 3)
@@ -103,7 +103,7 @@ func TestAdminAPI_PatchUser_TempBanOutOfRange(t *testing.T) {
// TestAdminAPI_PatchUser_InvalidBody verifies that a non-JSON body returns 400.
func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "invalidbody", "hash", 3)
@@ -125,7 +125,7 @@ func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) {
// "type" field causes the channel to be created with type "text".
func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]any{
@@ -150,7 +150,7 @@ func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) {
// TestAdminAPI_CreateChannel_InvalidBody verifies that a malformed body returns 400.
func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader([]byte("not-json")))
@@ -170,7 +170,7 @@ func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) {
// the URL returns 400.
func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/users/notanumber/sessions", token, nil)
@@ -186,7 +186,7 @@ func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) {
// returns 400.
func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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(), "malformed", "text", "", "", 0)
@@ -208,7 +208,7 @@ func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) {
// to 500 (testing the queryInt cap branch).
func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Passing limit=9999 should be silently capped to 500.
@@ -225,7 +225,7 @@ func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) {
// when no updater is configured.
func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
@@ -241,7 +241,7 @@ func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) {
// returns 400.
func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/channels/notanumber", token, nil)
@@ -257,7 +257,7 @@ func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) {
// returns 400.
func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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": "x"}
@@ -273,7 +273,7 @@ func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) {
// TestAdminAPI_AuditLog_Pagination verifies that limit and offset params work.
func TestAdminAPI_AuditLog_Pagination(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Create several audit entries.
@@ -304,7 +304,7 @@ func TestAdminAPI_AuditLog_Pagination(t *testing.T) {
// hub is nil (the OnlineCount field defaults to 0).
func TestAdminAPI_Stats_NilHub(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
@@ -331,7 +331,7 @@ func TestAdminAPI_Stats_NilHub(t *testing.T) {
// falls back to the default (testing the queryInt error-fallback branch).
func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=notanumber", token, nil)
@@ -345,7 +345,7 @@ func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) {
// the default (testing the n < 1 branch of queryInt).
func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// limit=0 triggers the n < 1 fallback in queryInt
@@ -363,7 +363,7 @@ func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) {
// BroadcastMemberBan).
func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "ban-nohub", "hash", 3)
@@ -388,7 +388,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) {
func TestAdminAPI_LogStreamTicketFlow(t *testing.T) {
database := openAdminTestDB(t)
logBuf := admin.NewRingBuffer(8)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
ticketResp := doRequest(t, handler, http.MethodPost, "/logs/ticket", token, nil)
@@ -483,7 +483,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) {
// around BroadcastMemberUpdate).
func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "role-nohub", "hash", 3)
@@ -508,7 +508,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) {
// providing ban_reason is accepted (reason defaults to empty string).
func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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(), "banwithout", "hash", 3)
@@ -529,7 +529,7 @@ func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) {
func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "rolebroadcast", "hash", 3)
@@ -551,7 +551,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) {
// needs_setup=true when the database has no users.
func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil)
@@ -571,7 +571,7 @@ func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) {
// TestAdminAPI_SetupStatus_AlreadySetup verifies needs_setup=false when users exist.
func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, _ = database.CreateUser(context.Background(), "existing", "hash", 1)
@@ -592,7 +592,7 @@ func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) {
// session, channel, and invite.
func TestAdminAPI_Setup_Success(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
body := map[string]string{
"username": "owner",
@@ -623,7 +623,7 @@ func TestAdminAPI_Setup_Success(t *testing.T) {
// when users already exist.
func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, _ = database.CreateUser(context.Background(), "existing", "hash", 1)
@@ -642,7 +642,7 @@ func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) {
// username or password returns 400.
func TestAdminAPI_Setup_MissingFields(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
body := map[string]string{
"username": "",
@@ -658,7 +658,7 @@ func TestAdminAPI_Setup_MissingFields(t *testing.T) {
// TestAdminAPI_Setup_WeakPassword verifies that a weak password is rejected.
func TestAdminAPI_Setup_WeakPassword(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
body := map[string]string{
"username": "owner",
@@ -674,7 +674,7 @@ func TestAdminAPI_Setup_WeakPassword(t *testing.T) {
// TestAdminAPI_Setup_InvalidBody verifies that a non-JSON body returns 400.
func TestAdminAPI_Setup_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json")))
w := httptest.NewRecorder()
+60 -66
View File
@@ -27,12 +27,6 @@ func newTestModService(database *db.DB) *service.ModerationService {
return service.NewModerationService(st, service.NewPermissionService(st, checker))
}
// newTestSettingsService builds a real SettingsService over the test
// database so the settings routes exercise the same policy production runs.
func newTestSettingsService(database *db.DB) *service.SettingsService {
return service.NewSettingsService(database)
}
// newTestRoleService builds a real RoleService over the test database so the
// role routes exercise the production authorization (MANAGE_ROLES + hierarchy)
// instead of a stub.
@@ -268,7 +262,7 @@ func doRequest(t *testing.T, handler http.Handler, method, path, token string, b
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), newTestSettingsService(database))
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)
@@ -291,7 +285,7 @@ func TestAdminAPI_Stats_OK(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
@@ -302,7 +296,7 @@ func TestAdminAPI_Stats_Unauthenticated(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -316,7 +310,7 @@ func TestAdminAPI_Stats_Forbidden(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -337,7 +331,7 @@ func TestAdminAPI_ListUsers_OK(t *testing.T) {
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), newTestSettingsService(database))
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
@@ -350,7 +344,7 @@ func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodGet, "/users", "", nil)
@@ -368,7 +362,7 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
// 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), newTestSettingsService(database))
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.
@@ -431,7 +425,7 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Create a target user
@@ -459,7 +453,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -481,7 +475,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) {
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), newTestSettingsService(database))
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}
@@ -494,7 +488,7 @@ func TestAdminAPI_PatchUser_NotFound(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -508,7 +502,7 @@ func TestAdminAPI_PatchUser_InvalidID(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -528,7 +522,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -541,7 +535,7 @@ func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -565,7 +559,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]any{
@@ -592,7 +586,7 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]any{
@@ -609,7 +603,7 @@ func TestAdminAPI_CreateChannel_MissingName(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -630,7 +624,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) {
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), newTestSettingsService(database))
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"}
@@ -645,7 +639,7 @@ func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -665,7 +659,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
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), newTestSettingsService(database))
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
@@ -728,7 +722,7 @@ func TestAdminAPI_DeleteChannel_CleansVoiceBeforeDBDelete(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -754,7 +748,7 @@ func TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -768,7 +762,7 @@ func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -791,7 +785,7 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -811,7 +805,7 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -833,7 +827,7 @@ func TestAdminAPI_GetSettings_OK(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -858,7 +852,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) {
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), newTestSettingsService(database))
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")))
@@ -875,7 +869,7 @@ func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -892,7 +886,7 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
@@ -909,7 +903,7 @@ func TestAdminAPI_Backup_Unauthenticated(t *testing.T) {
// 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), newTestSettingsService(database))
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.
@@ -943,7 +937,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) {
// 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), newTestSettingsService(database))
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)
@@ -975,7 +969,7 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) {
// 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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -1001,7 +995,7 @@ func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) {
// 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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -1045,7 +1039,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
value := "testvalue"
@@ -1066,7 +1060,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) {
// (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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]string{}
@@ -1079,7 +1073,7 @@ func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -1095,7 +1089,7 @@ func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing
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), newTestSettingsService(database))
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 {
@@ -1115,7 +1109,7 @@ func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrat
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -1137,7 +1131,7 @@ func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) {
// re-run the enrollment count for a key nobody asked to change).
func TestAdminAPI_PatchSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Enroll the admin so the initial require_2fa enable succeeds.
@@ -1183,7 +1177,7 @@ func TestAdminAPI_PatchSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testin
// 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), newTestSettingsService(database))
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.
@@ -1210,7 +1204,7 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) {
// 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), newTestSettingsService(database))
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)
@@ -1229,7 +1223,7 @@ func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) {
// 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), newTestSettingsService(database))
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)
@@ -1258,7 +1252,7 @@ func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) {
// 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), newTestSettingsService(database))
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)
@@ -1286,7 +1280,7 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) {
// 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), newTestSettingsService(database))
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)
@@ -1387,7 +1381,7 @@ func (m *mockHub) ClientCount() int {
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), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
body := map[string]any{
@@ -1410,7 +1404,7 @@ func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) {
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), newTestSettingsService(database))
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"}
@@ -1424,7 +1418,7 @@ func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -1445,7 +1439,7 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -1460,7 +1454,7 @@ func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -1480,7 +1474,7 @@ func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -1509,7 +1503,7 @@ func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) {
func TestAdminAPI_DeleteChannel_SurvivesContextCancelAfterArchiveCommits(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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-cancel-race", "text", "", "", 0)
@@ -1549,7 +1543,7 @@ func TestAdminAPI_DeleteChannel_SurvivesContextCancelAfterArchiveCommits(t *test
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), newTestSettingsService(database))
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"})
@@ -1579,7 +1573,7 @@ func TestAdminAPI_CreateAPIToken_OK(t *testing.T) {
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), newTestSettingsService(database))
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": " "})
@@ -1594,7 +1588,7 @@ func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) {
// negative value down the nil-expiresAt ("never expires") branch.
func TestAdminAPI_CreateAPIToken_NegativeExpiresHours(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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": "neg-hours", "expires_hours": -1})
@@ -1615,7 +1609,7 @@ func TestAdminAPI_CreateAPIToken_NegativeExpiresHours(t *testing.T) {
// timestamp and hand back a token that 401s on first use.
func TestAdminAPI_CreateAPIToken_HugeExpiresHours(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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": "huge-hours", "expires_hours": 3000000})
@@ -1633,7 +1627,7 @@ func TestAdminAPI_CreateAPIToken_HugeExpiresHours(t *testing.T) {
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), newTestSettingsService(database))
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")
@@ -1662,7 +1656,7 @@ func TestAdminAPI_ListAPITokens_OK(t *testing.T) {
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), newTestSettingsService(database))
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")
@@ -1684,7 +1678,7 @@ func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) {
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), newTestSettingsService(database))
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)
@@ -1698,7 +1692,7 @@ func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) {
// 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), newTestSettingsService(database))
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"
@@ -1712,7 +1706,7 @@ func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) {
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), newTestSettingsService(database))
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 {
+1 -1
View File
@@ -27,7 +27,7 @@ func TestAuditCoverage_AdminMutations(t *testing.T) {
t.Helper()
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, &mockPermInvalidator{},
newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
if err != nil {
+5 -6
View File
@@ -12,7 +12,6 @@ import (
"time"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/service"
)
// Scheduled-backup intervals for the backup_schedule setting values the admin
@@ -36,8 +35,8 @@ const (
//
// The returned error feeds the maintenance loop's circuit breaker; settings
// simply not existing (fresh DB mid-migration) is not an error.
func MaintainBackups(ctx context.Context, database *db.DB, settings *service.SettingsService) error {
schedule, err := settings.Setting(ctx, "backup_schedule")
func MaintainBackups(ctx context.Context, database *db.DB) error {
schedule, err := database.GetSetting(ctx, "backup_schedule")
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil
@@ -61,7 +60,7 @@ func MaintainBackups(ctx context.Context, database *db.DB, settings *service.Set
}
}
if err := pruneExpiredBackups(ctx, database, settings); err != nil {
if err := pruneExpiredBackups(ctx, database); err != nil {
slog.Warn("backup retention pruning failed", "error", err)
if firstErr == nil {
firstErr = err
@@ -119,8 +118,8 @@ func runScheduledBackup(ctx context.Context, database *db.DB, interval time.Dura
// pruneExpiredBackups deletes *.db backups whose mtime is older than the
// backup_retention window (in days), always keeping the newest one.
func pruneExpiredBackups(ctx context.Context, database *db.DB, settings *service.SettingsService) error {
retStr, err := settings.Setting(ctx, "backup_retention")
func pruneExpiredBackups(ctx context.Context, database *db.DB) error {
retStr, err := database.GetSetting(ctx, "backup_retention")
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil
+7 -8
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/J3vb/OwnCord/Server/admin"
"github.com/J3vb/OwnCord/Server/service"
)
func listBackupFiles(t *testing.T, dir string) []string {
@@ -50,7 +49,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
ctx := context.Background()
// Settings absent → no-op, no error.
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
if err := admin.MaintainBackups(ctx, database); err != nil {
t.Fatalf("MaintainBackups with no settings: %v", err)
}
if got := listBackupFiles(t, dir); len(got) != 0 {
@@ -60,7 +59,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
// Schedule off → still a no-op.
mustSetSetting(t, database, "backup_schedule", "off")
mustSetSetting(t, database, "backup_retention", "7")
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
if err := admin.MaintainBackups(ctx, database); err != nil {
t.Fatalf("MaintainBackups with schedule=off: %v", err)
}
if got := listBackupFiles(t, dir); len(got) != 0 {
@@ -69,7 +68,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
// Daily → first tick creates exactly one scheduled backup.
mustSetSetting(t, database, "backup_schedule", "daily")
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
if err := admin.MaintainBackups(ctx, database); err != nil {
t.Fatalf("MaintainBackups daily #1: %v", err)
}
files := listBackupFiles(t, dir)
@@ -79,7 +78,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
first := filepath.Join(dir, files[0])
// Fresh backup on disk → next tick is a no-op.
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
if err := admin.MaintainBackups(ctx, database); err != nil {
t.Fatalf("MaintainBackups daily #2: %v", err)
}
if got := listBackupFiles(t, dir); len(got) != 1 {
@@ -89,7 +88,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
// Backup older than a day (but inside retention) → a new one is taken and
// the old one is kept.
backdate(t, first, 25*time.Hour)
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
if err := admin.MaintainBackups(ctx, database); err != nil {
t.Fatalf("MaintainBackups daily #3: %v", err)
}
if got := listBackupFiles(t, dir); len(got) != 2 {
@@ -98,7 +97,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
// Old backup past the 7-day retention window → pruned; the fresh one stays.
backdate(t, first, 8*24*time.Hour)
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
if err := admin.MaintainBackups(ctx, database); err != nil {
t.Fatalf("MaintainBackups daily #4: %v", err)
}
got := listBackupFiles(t, dir)
@@ -133,7 +132,7 @@ func TestMaintainBackups_RetentionNeverDeletesNewest(t *testing.T) {
backdate(t, older, 30*24*time.Hour)
backdate(t, newer, 20*24*time.Hour)
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
if err := admin.MaintainBackups(ctx, database); err != nil {
t.Fatalf("MaintainBackups: %v", err)
}
got := listBackupFiles(t, dir)
+4 -4
View File
@@ -20,7 +20,7 @@ import (
func TestAdminAPI_PatchChannel_ArchiveCleansVoice(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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(), "archive-voice", "voice", "", "", 0)
@@ -47,7 +47,7 @@ func TestAdminAPI_PatchChannel_ArchiveCleansVoice(t *testing.T) {
func TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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(), "unarchive-voice", "voice", "", "", 0)
@@ -89,7 +89,7 @@ func TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(t *testing.T) {
func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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(), "archive-cancel-race", "voice", "", "", 0)
@@ -138,7 +138,7 @@ func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testin
func TestAdminAPI_CreateChannel_SurvivesContextCancelAfterCommit(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
ctx, cancel := context.WithCancel(context.Background())
+17 -17
View File
@@ -46,7 +46,7 @@ func chdirTemp(t *testing.T) string {
func TestHandleBackup_Success(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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, "/backup", token, nil)
@@ -81,7 +81,7 @@ func TestHandleBackup_Success(t *testing.T) {
func TestHandleBackup_RequiresOwner(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
adminUID, _ := database.CreateUser(context.Background(), "backupadmin", "hash", 2)
token := "backup-admin-token"
@@ -101,7 +101,7 @@ func TestHandleBackup_RequiresOwner(t *testing.T) {
func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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, "/backups", token, nil)
@@ -124,7 +124,7 @@ func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) {
func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Create a backup first.
@@ -166,7 +166,7 @@ func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) {
func TestHandleDeleteBackup_Success(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Create a real backup file to delete.
@@ -197,7 +197,7 @@ func TestHandleDeleteBackup_Success(t *testing.T) {
func TestHandleDeleteBackup_NotFound(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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, "/backups/nonexistent.db", token, nil)
@@ -212,7 +212,7 @@ func TestHandleDeleteBackup_NotFound(t *testing.T) {
func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// The chi router URL-decodes the path parameter, so ".." arrives decoded.
@@ -230,7 +230,7 @@ func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) {
func TestHandleDeleteBackup_RequiresOwner(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
adminUID, _ := database.CreateUser(context.Background(), "deladmin", "hash", 2)
token := "del-admin-token"
@@ -255,7 +255,7 @@ func TestHandleDeleteBackup_RequiresOwner(t *testing.T) {
func TestHandleRestoreBackup_Success(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Set up backup and data directories.
@@ -359,7 +359,7 @@ func TestHandleRestoreBackup_Success(t *testing.T) {
func TestHandleRestoreBackup_RollsBackWhenCopyFails(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
backupDir := filepath.Join(tmpDir, "data", "backups")
@@ -453,7 +453,7 @@ func TestHandleRestoreBackup_RollsBackWhenCopyFails(t *testing.T) {
func TestHandleRestoreBackup_RestartsWhenCloseFails(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
backupDir := filepath.Join(tmpDir, "data", "backups")
@@ -500,7 +500,7 @@ func TestHandleRestoreBackup_RestartsWhenCloseFails(t *testing.T) {
func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
backupDir := filepath.Join(tmpDir, "data", "backups")
@@ -556,7 +556,7 @@ func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) {
func TestHandleRestoreBackup_UsesConfiguredDatabasePath(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
backupDir := filepath.Join(tmpDir, "data", "backups")
@@ -622,7 +622,7 @@ func TestHandleRestoreBackup_UsesConfiguredDatabasePath(t *testing.T) {
func TestHandleRestoreBackup_NotFound(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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, "/backups/missing.db/restore", token, nil)
@@ -637,7 +637,7 @@ func TestHandleRestoreBackup_NotFound(t *testing.T) {
func TestHandleRestoreBackup_InvalidName(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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, "/backups/..evil.db/restore", token, nil)
@@ -653,7 +653,7 @@ func TestHandleRestoreBackup_InvalidName(t *testing.T) {
func TestHandleListBackups_ErrorReadingDir(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
// Create data/ directory but make "backups" a file instead of a directory.
@@ -681,7 +681,7 @@ func TestHandleListBackups_ErrorReadingDir(t *testing.T) {
func TestHandleRestoreBackup_RequiresOwner(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
adminUID, _ := database.CreateUser(context.Background(), "restoreadmin", "hash", 2)
token := "restore-admin-token"
+15 -15
View File
@@ -29,7 +29,7 @@ func (m *mockPermInvalidator) InvalidateAll() {
func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
@@ -67,7 +67,7 @@ func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) {
func TestGetChannelPermissions_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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, "/channels/9999/permissions", token, nil)
@@ -78,7 +78,7 @@ func TestGetChannelPermissions_NotFound(t *testing.T) {
func TestGetChannelPermissions_DMRejected(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0)
@@ -99,7 +99,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
inv := &mockPermInvalidator{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
@@ -160,7 +160,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) {
func TestPutChannelPermission_MasksUnknownBits(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "secret2", "text", "", "", 0)
@@ -190,7 +190,7 @@ func TestPutChannelPermission_MasksUnknownBits(t *testing.T) {
func TestPutChannelPermission_UnknownRole(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "secret3", "text", "", "", 0)
@@ -207,7 +207,7 @@ func TestPutChannelPermission_UnknownRole(t *testing.T) {
func TestPutChannelPermission_NonAdminForbidden(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_ = createAdminUser(t, database)
memberToken := createMemberUser(t, database)
@@ -228,7 +228,7 @@ func TestPutChannelPermission_NonAdminForbidden(t *testing.T) {
// a channel override — the escalation this override endpoint must refuse.
func TestPutChannelPermission_ModeratorCannotEscalate(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
chID, err := database.CreateChannel(context.Background(), "escalate", "text", "", "", 0)
@@ -258,7 +258,7 @@ func TestPutChannelPermission_ModeratorCannotEscalate(t *testing.T) {
// a channel override, since ADMINISTRATOR bypasses the escalation guard.
func TestPutChannelPermission_AdministratorCanGrantAnyBit(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "admin-grant", "text", "", "", 0)
@@ -287,7 +287,7 @@ func TestPutChannelPermission_AdministratorCanGrantAnyBit(t *testing.T) {
// the actor's own mask — mirroring service.requireBelowActor.
func TestPutChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
chID, err := database.CreateChannel(context.Background(), "hierarchy", "text", "", "", 0)
@@ -320,7 +320,7 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
inv := &mockPermInvalidator{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "secret5", "text", "", "", 0)
@@ -373,7 +373,7 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) {
// TestPutChannelPermission_RefusesEqualOrHigherRole (A-2026-08-01).
func TestDeleteChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
chID, err := database.CreateChannel(context.Background(), "hierarchy-del", "text", "", "", 0)
@@ -417,7 +417,7 @@ func TestDeleteChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
// (TestPutChannelPermission_UnknownRole).
func TestDeleteChannelPermission_UnknownRole(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "hierarchy-del-404", "text", "", "", 0)
@@ -440,7 +440,7 @@ func TestDeleteChannelPermission_UnknownRole(t *testing.T) {
// not skip it just because the hierarchy guard alone passes.
func TestDeleteChannelPermission_EscalationGuard(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Helper role: low position, base permissions include MANAGE_MESSAGES.
if _, err := database.ExecContext(context.Background(),
@@ -485,7 +485,7 @@ func TestDeleteChannelPermission_EscalationGuard(t *testing.T) {
// by this write, not just the (trivially empty) bits being written.
func TestPutChannelPermission_ClearByZeroMaskEscalationGuard(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
if _, err := database.ExecContext(context.Background(),
`INSERT INTO roles (id, name, color, permissions, position, is_default)
@@ -30,7 +30,7 @@ func TestPutChannelUserPermission_PersistsInvalidatesAndAudits(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
inv := &mockPermInvalidator{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
target := seedOverrideTarget(t, database, "override-target")
@@ -95,7 +95,7 @@ func TestPutChannelUserPermission_PersistsInvalidatesAndAudits(t *testing.T) {
// editor writes — one bit per row, in both directions at once.
func TestPutChannelUserPermission_MaskRoundTrip(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
target := seedOverrideTarget(t, database, "matrix-target")
@@ -135,7 +135,7 @@ func TestPutChannelUserPermission_MaskRoundTrip(t *testing.T) {
func TestPutChannelUserPermission_MasksUnknownBits(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
target := seedOverrideTarget(t, database, "junk-target")
@@ -162,7 +162,7 @@ func TestPutChannelUserPermission_MasksUnknownBits(t *testing.T) {
func TestPutChannelUserPermission_UnknownUser(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "nope", "text", "", "", 0)
@@ -179,7 +179,7 @@ func TestPutChannelUserPermission_UnknownUser(t *testing.T) {
func TestPutChannelUserPermission_DMRejected(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
target := seedOverrideTarget(t, database, "dm-target")
@@ -197,7 +197,7 @@ func TestPutChannelUserPermission_DMRejected(t *testing.T) {
func TestChannelUserPermission_NonAdminForbidden(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_ = createAdminUser(t, database)
memberToken := createMemberUser(t, database)
target := seedOverrideTarget(t, database, "forbidden-target")
@@ -231,7 +231,7 @@ func TestChannelUserPermission_NonAdminForbidden(t *testing.T) {
// writing it into a per-user channel override.
func TestPutChannelUserPermission_ModeratorCannotEscalate(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
target := seedOverrideTarget(t, database, "escalate-target")
@@ -263,7 +263,7 @@ func TestPutChannelUserPermission_ModeratorCannotEscalate(t *testing.T) {
// access their role grants.
func TestPutChannelUserPermission_CannotTargetHigherRankedUser(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Actor: Moderator at position 60 holding MANAGE_CHANNELS + READ_MESSAGES.
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "mod-hier")
// Target holds a role ranked ABOVE the actor.
@@ -300,7 +300,7 @@ func TestPutChannelUserPermission_CannotTargetHigherRankedUser(t *testing.T) {
// override, since ADMINISTRATOR bypasses the escalation guard.
func TestPutChannelUserPermission_AdministratorCanGrantAnyBit(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
target := seedOverrideTarget(t, database, "admin-grant-target")
@@ -329,7 +329,7 @@ func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
inv := &mockPermInvalidator{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
target := seedOverrideTarget(t, database, "clear-target")
@@ -391,7 +391,7 @@ func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) {
// passes.
func TestDeleteChannelUserPermission_EscalationGuard(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Actor: MANAGE_CHANNELS holder without MANAGE_MESSAGES or ADMINISTRATOR.
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
target := seedOverrideTarget(t, database, "escalate-del-target")
@@ -424,7 +424,7 @@ func TestDeleteChannelUserPermission_EscalationGuard(t *testing.T) {
// (TestPutChannelPermission_ClearByZeroMaskEscalationGuard's per-user twin).
func TestPutChannelUserPermission_ClearByZeroMaskEscalationGuard(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
target := seedOverrideTarget(t, database, "escalate-zero-target")
+2 -2
View File
@@ -23,7 +23,7 @@ import (
func newChannelTestAPI(t *testing.T) (http.Handler, string, *db.DB) {
t.Helper()
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
return handler, createAdminUser(t, database), database
}
@@ -352,7 +352,7 @@ func TestPatchChannel_RejectedPatchWritesNothing(t *testing.T) {
func TestPatchChannel_BroadcastCarriesFeatureFlags(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
id := newChannel(t, handler, token, "lounge", "voice")
+2 -2
View File
@@ -25,7 +25,7 @@ func newRolesHandler(t *testing.T, database *db.DB) (http.Handler, *mockHub, *mo
hub := &mockHub{}
inv := &mockPermInvalidator{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv,
newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
newTestModService(database), newTestRoleService(database))
return handler, hub, inv, createAdminUser(t, database)
}
@@ -155,7 +155,7 @@ func TestAdminAPI_Roles_ServiceUnavailableFailsClosed(t *testing.T) {
// nil RoleService: the routes must refuse rather than fall through to an
// unchecked write.
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil,
newTestModService(database), nil, newTestSettingsService(database))
newTestModService(database), nil)
token := createAdminUser(t, database)
for _, tc := range []struct {
+143 -14
View File
@@ -1,31 +1,31 @@
package admin
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/J3vb/OwnCord/Server/service"
"github.com/J3vb/OwnCord/Server/db"
)
// ─── Settings Handlers ──────────────────────────────────────────────────────
//
// Thin adapters over service.SettingsService (B3-8 settings/audit family):
// the whitelist, boolean normalization, require_2fa preconditions, atomic
// apply and audit rows all live in the service.
func handleGetSettings(settings *service.SettingsService) http.HandlerFunc {
func handleGetSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
all, err := settings.List(r.Context())
settings, err := database.GetAllSettings(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
return
}
writeJSON(w, http.StatusOK, all)
writeJSON(w, http.StatusOK, settings)
}
}
func handlePatchSettings(settings *service.SettingsService) http.HandlerFunc {
func handlePatchSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var updates map[string]string
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
@@ -33,15 +33,144 @@ func handlePatchSettings(settings *service.SettingsService) http.HandlerFunc {
return
}
all, err := settings.Patch(r.Context(), actorFromContext(r), updates)
if errors.Is(err, service.ErrBadRequest) {
// Validate all keys against the whitelist before writing anything so
// the operation is atomic from the caller's perspective.
for key := range updates {
if _, ok := allowedSettingKeys[key]; !ok {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST",
fmt.Sprintf("unknown setting key: %q", key))
return
}
}
normalizedUpdates, err := normalizeSettingUpdates(updates)
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update settings")
if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
writeJSON(w, http.StatusOK, all)
actor := actorFromContext(r)
// Apply all settings atomically so a mid-loop failure doesn't leave
// partial updates.
tx, err := database.BeginTx(r.Context(), nil)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to start transaction")
return
}
for key, value := range normalizedUpdates {
if _, txErr := tx.ExecContext(r.Context(),
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value,
); txErr != nil {
_ = tx.Rollback()
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
return
}
}
if err := tx.Commit(); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to commit settings")
return
}
for key := range normalizedUpdates {
slog.Info("setting changed", "actor_id", actor, "key", key)
db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "setting_change", "setting", 0,
fmt.Sprintf("%s updated", key))
}
settings, err := database.GetAllSettings(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
func normalizeSettingUpdates(updates map[string]string) (map[string]string, error) {
normalized := make(map[string]string, len(updates))
for key, value := range updates {
normalized[key] = value
switch key {
case "require_2fa", "registration_open":
parsed, err := parseBooleanSettingValue(value)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
if parsed {
normalized[key] = "1"
} else {
normalized[key] = "0"
}
}
}
return normalized, nil
}
func validateRequire2FAUpdate(ctx context.Context, database *db.DB, updates map[string]string) error {
targetRequire2FA, err := targetBoolSetting(ctx, database, updates, "require_2fa")
if err != nil {
return err
}
if !targetRequire2FA {
return nil
}
registrationOpen, err := targetBoolSetting(ctx, database, updates, "registration_open")
if err != nil {
return err
}
if registrationOpen {
return fmt.Errorf("require_2fa cannot be enabled while registration is open")
}
// The enrollment count only matters when this request is actually turning
// require_2fa on. Without this guard, an unrelated PATCH (motd, server
// name, backup settings, ...) inherits require_2fa's *current* value via
// targetBoolSetting's DB fallback and gets rejected by a precondition
// about a value it never touches — wedging the whole settings page once
// any non-banned user without TOTP exists.
if _, changingRequire2FA := updates["require_2fa"]; !changingRequire2FA {
return nil
}
count, err := database.CountUsersWithoutTOTP(ctx)
if err != nil {
return fmt.Errorf("failed to validate 2FA enrollment")
}
if count > 0 {
return fmt.Errorf("require_2fa cannot be enabled until all users have 2FA enabled")
}
return nil
}
func targetBoolSetting(ctx context.Context, database *db.DB, updates map[string]string, key string) (bool, error) {
if value, ok := updates[key]; ok {
return parseBooleanSettingValue(value)
}
value, err := database.GetSetting(ctx, key)
if errors.Is(err, db.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return parseBooleanSettingValue(value)
}
func parseBooleanSettingValue(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true":
return true, nil
case "0", "false":
return false, nil
default:
return false, fmt.Errorf("invalid boolean value %q", value)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
func TestAdminAPI_PatchUser_RefusedRoleChangeDoesNotLeaveBanCommitted(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Moderator: BAN_MEMBERS (and everything below bit 20), but not
// MANAGE_ROLES (bit 24) — moderatorMask is perm_gates_test.go's constant
@@ -29,7 +29,7 @@ func (m *unbanMockHub) BroadcastMemberUnban(userID int64) {
func TestAdminAPI_PatchUser_UnbanBroadcastsMemberUnban(t *testing.T) {
database := openAdminTestDB(t)
hub := &unbanMockHub{mockHub: &mockHub{}}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "unbanbroadcast", "hash", 3)
@@ -63,7 +63,7 @@ func TestAdminAPI_PatchUser_UnbanBroadcastsMemberUnban(t *testing.T) {
func TestAdminAPI_PatchUser_RoleChangeRefreshesVisibility(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "rolerefresh", "hash", 3)
@@ -120,7 +120,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcastsEvenIfRoleReReadFails(t *testing
database := openAdminTestDB(t)
hub := &mockHub{}
invalidator := &roleDeletingInvalidator{database: database, deleteRoleID: 2, fallbackRoleID: 3}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, invalidator, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, invalidator, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "roleracetarget", "hash", 3)
+1 -1
View File
@@ -34,7 +34,7 @@ func TestAdminAPI_PatchRole_MemberLookupFailureBlanketInvalidates(t *testing.T)
service.NewPermissionService(database, permissions.NewChecker(database)),
)
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv,
newTestModService(database), roleSvc, newTestSettingsService(database))
newTestModService(database), roleSvc)
token := createAdminUser(t, database)
// Strip the seeded Member role down to READ_MESSAGES — a permissions
+1 -1
View File
@@ -22,7 +22,7 @@ func TestAdminAPI_LogStreamTicketFlow_APIToken(t *testing.T) {
database := openAdminTestDB(t)
logBuf := admin.NewRingBuffer(8)
logBuf.Write(admin.LogEntry{Timestamp: "2026-07-31T10:00:00Z", Level: "INFO", Message: "hello from ring", Source: "server"})
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database))
// An admin user authenticated only by an API token — no session row exists.
uid, err := database.CreateUser(context.Background(), "apitokenadmin", "$2a$12$placeholder", 1)
+20 -11
View File
@@ -116,22 +116,31 @@ func requirePerm(perm int64) func(http.Handler) http.Handler {
}
}
// ownerOnlyMiddleware wraps a handler to require the Owner role
// (position == permissions.OwnerRolePosition). It consumes the *db.Role that
// adminAuthMiddleware resolved and stored in the request context — the same
// contract as requirePerm, so no second role read runs and no read-fault
// error mapping exists here at all: OC-0345's 503 branch died with the query
// it served, and OC-0379 pins the absence (a role read fault now surfaces
// once, at the perimeter, as its 503). A request that somehow arrives without
// the context role fails closed as unauthenticated.
func ownerOnlyMiddleware(next http.Handler) http.Handler {
// ownerOnlyMiddleware wraps a handler to require Owner role (position == 100).
// It reads the user from context (set by adminAuthMiddleware) rather than
// re-authenticating, avoiding redundant DB queries and session-expiry gaps.
func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role, ok := r.Context().Value(adminRoleKey).(*db.Role)
if !ok || role == nil {
user, ok := r.Context().Value(adminUserKey).(*db.User)
if !ok || user == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
return
}
role, err := database.GetRoleByID(r.Context(), user.RoleID)
if err != nil {
// A read fault is an outage, not a missing role: answering 403
// would tell the Owner they lack the Owner role. Mirror the
// perimeter's contract above — log it, report 503 (OC-0345).
slog.ErrorContext(r.Context(), "admin: owner role lookup failed", "error", err)
writeErr(w, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "authorization service temporarily unavailable")
return
}
if role == nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found")
return
}
if role.Position < permissions.OwnerRolePosition {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "owner role required")
return
+54 -89
View File
@@ -12,8 +12,6 @@ import (
"path/filepath"
"runtime"
"testing"
"github.com/J3vb/OwnCord/Server/service"
"testing/fstest"
"github.com/J3vb/OwnCord/Server/auth"
@@ -140,42 +138,38 @@ CREATE TABLE IF NOT EXISTS invites (
// ─── ownerOnlyMiddleware whitebox tests ──────────────────────────────────────
// TestOwnerOnlyMiddleware_NoUserInContext verifies that ownerOnlyMiddleware
// returns 401 when the request context carries no authenticated principal —
// simulates a call bypassing adminAuthMiddleware, which is what stores the
// role the gate consumes.
// returns 401 when there is no user stored in the request context.
func TestOwnerOnlyMiddleware_NoUserInContext(t *testing.T) {
database := openWhiteboxTestDB(t)
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(next)
handler := ownerOnlyMiddleware(database, next)
// Request with NO user in context — simulates a call bypassing adminAuthMiddleware.
req := httptest.NewRequest(http.MethodPost, "/backup", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if reached {
t.Error("next handler was reached despite an unauthenticated context")
t.Error("next handler was reached despite missing user in context")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
// TestOwnerOnlyMiddleware_UserWithoutRoleInContext verifies the gate fails
// closed as 401 when the context carries a user but no role. Through the full
// stack this cannot happen — adminAuthMiddleware stores both or refuses the
// request (a genuinely missing role is its 401, a role read fault its 503) —
// so a half-populated context means the perimeter did not run, and the gate
// must treat that as unauthenticated rather than consult the database itself.
// (The pre-OC-0379 middleware answered this shape by re-reading the role; the
// old TestOwnerOnlyMiddleware_RoleNotFound covered that lookup's miss, a
// branch that no longer exists.)
func TestOwnerOnlyMiddleware_UserWithoutRoleInContext(t *testing.T) {
// TestOwnerOnlyMiddleware_RoleNotFound verifies that ownerOnlyMiddleware
// returns 403 when the user's role_id does not exist in the database.
func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) {
database := openWhiteboxTestDB(t)
// Create a user initially with a valid role, then mutate role_id to a
// nonexistent value (disabling FK checks temporarily so SQLite allows it).
uid, err := database.CreateUser(context.Background(), "orphanuser", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
@@ -185,60 +179,45 @@ func TestOwnerOnlyMiddleware_UserWithoutRoleInContext(t *testing.T) {
t.Fatalf("GetUserByID: %v", err)
}
// Disable FK enforcement, update role_id, re-enable.
if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil {
t.Fatalf("disable FK: %v", err)
}
if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil {
t.Fatalf("UPDATE role_id: %v", err)
}
if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil {
t.Fatalf("re-enable FK: %v", err)
}
user.RoleID = 9999 // mirror the DB value in our in-memory struct
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(next)
handler := ownerOnlyMiddleware(database, next)
// User injected, role deliberately absent.
// Inject user into context as adminAuthMiddleware would.
ctx := context.WithValue(context.Background(), adminUserKey, user)
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if reached {
t.Error("next handler was reached despite no role in context")
t.Error("next handler was reached despite missing role")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 (no role in context is unauthenticated)", w.Code)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403 (role not found)", w.Code)
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["error"] != "UNAUTHORIZED" {
t.Errorf("error = %q, want UNAUTHORIZED", resp["error"])
}
}
// TestOwnerOnlyMiddleware_BelowOwnerForbidden verifies a role below the Owner
// position is refused with 403 "owner role required". The role comes straight
// from the context — the middleware reads nothing else, so a plain struct is
// the whole setup.
func TestOwnerOnlyMiddleware_BelowOwnerForbidden(t *testing.T) {
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(next)
mod := &db.Role{ID: 2, Name: "Moderator", Position: 50}
ctx := context.WithValue(context.Background(), adminRoleKey, mod)
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if reached {
t.Error("next handler was reached for a below-owner role")
}
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403 (owner role required)", w.Code)
if resp["error"] != "FORBIDDEN" {
t.Errorf("error = %q, want FORBIDDEN", resp["error"])
}
}
@@ -255,10 +234,6 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
}
role, err := database.GetRoleByID(context.Background(), user.RoleID)
if err != nil || role == nil {
t.Fatalf("GetRoleByID: %v", err)
}
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -266,11 +241,9 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(next)
handler := ownerOnlyMiddleware(database, next)
// Both keys, as adminAuthMiddleware stores them.
ctx := context.WithValue(context.Background(), adminUserKey, user)
ctx = context.WithValue(ctx, adminRoleKey, role)
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -289,7 +262,7 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
// role_id has been set to a nonexistent value returns 401.
func TestAdminAuthMiddleware_RoleNotFound(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
uid, err := database.CreateUser(context.Background(), "noroleuser", "$2a$12$x", 1)
if err != nil {
@@ -368,7 +341,7 @@ func TestHandleListChannels_DBError(t *testing.T) {
// when the database query fails.
func TestHandleGetSettings_DBError(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := handleGetSettings(service.NewSettingsService(database))
handler := handleGetSettings(database)
_ = database.Close()
@@ -550,26 +523,16 @@ func TestSpawnDetached_CommandConstruction(t *testing.T) {
}
}
// TestOwnerOnlyMiddleware_RoleLookupFailureIs503 pinned OC-0345's 503 mapping
// on the owner gate's own role read. OC-0379 removed that read entirely — the
// gate consumes adminRoleKey and issues no query, so the branch this test
// exercised no longer exists in any form. The 503-on-read-fault contract it
// protected still holds where the one remaining role read lives: the
// perimeter's default branch in adminAuthMiddleware (middleware.go), covered
// by its own tests. TestOwnerOnlyMiddleware_NoSecondRoleLookup below is the
// replacement pin: it renames the roles table away and requires the owner
// path to succeed anyway.
// TestOwnerOnlyMiddleware_NoSecondRoleLookup pins OC-0379 (OC-0345's residue):
// the owner gate consumes the role adminAuthMiddleware already resolved into
// the request context and performs no role read of its own. The roles table is
// renamed away exactly as the OC-0345 fault test did — if the middleware still
// issues a role query, that query fails and the request cannot reach 200, so
// this test is red for as long as the second lookup exists.
func TestOwnerOnlyMiddleware_NoSecondRoleLookup(t *testing.T) {
// TestOwnerOnlyMiddleware_RoleLookupFailureIs503 pins OC-0345: a database
// fault on the owner gate's role read is an outage, not a missing role, so the
// Owner must get 503 SERVICE_UNAVAILABLE — never the 403 "role not found" a
// genuinely absent role earns. Whitebox on purpose: through the full stack
// adminAuthMiddleware reads the role first and would answer its own 503, so
// the branch under test would never run.
func TestOwnerOnlyMiddleware_RoleLookupFailureIs503(t *testing.T) {
database := openWhiteboxTestDB(t)
uid, err := database.CreateUser(context.Background(), "ownerctx", "$2a$12$x", 1)
uid, err := database.CreateUser(context.Background(), "ownerfault", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
@@ -577,11 +540,7 @@ func TestOwnerOnlyMiddleware_NoSecondRoleLookup(t *testing.T) {
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
}
role, err := database.GetRoleByID(context.Background(), user.RoleID)
if err != nil || role == nil {
t.Fatalf("GetRoleByID: %v", err)
}
// After this, any role read fails: the only way to 200 is the context role.
// Every query against roles now fails with a non-sentinel error.
if _, err := database.ExecContext(context.Background(), `ALTER TABLE roles RENAME TO roles_gone`); err != nil {
t.Fatalf("hide roles: %v", err)
}
@@ -591,18 +550,24 @@ func TestOwnerOnlyMiddleware_NoSecondRoleLookup(t *testing.T) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(next)
handler := ownerOnlyMiddleware(database, next)
ctx := context.WithValue(context.Background(), adminUserKey, user)
ctx = context.WithValue(ctx, adminRoleKey, role)
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !reached {
t.Error("next handler was not reached: the owner gate performed a role lookup instead of consuming the context role")
if reached {
t.Error("next handler was reached although the role could not be read")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 with no role read", w.Code)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 (a role read fault is not a missing role)", w.Code)
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["error"] != "SERVICE_UNAVAILABLE" {
t.Errorf("error = %q, want SERVICE_UNAVAILABLE", resp["error"])
}
}
+4 -4
View File
@@ -19,7 +19,7 @@ import (
// session has expired is rejected with 401.
func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Create a user and session, then manually expire the session by setting
// expires_at to a past timestamp via the exported Exec helper.
@@ -54,7 +54,7 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) {
// access immediately, not only when the session expires.
func TestAdminAuthMiddleware_BannedAdmin(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
if w := doRequest(t, handler, http.MethodGet, "/stats", token, nil); w.Code != http.StatusOK {
@@ -78,7 +78,7 @@ func TestAdminAuthMiddleware_BannedAdmin(t *testing.T) {
// Authorization header returns 401.
func TestAdminAuthMiddleware_MissingBearer(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
@@ -91,7 +91,7 @@ func TestAdminAuthMiddleware_MissingBearer(t *testing.T) {
// sessions table returns 401.
func TestAdminAuthMiddleware_InvalidToken(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodGet, "/stats", "completely-invalid-token", nil)
+1 -1
View File
@@ -42,7 +42,7 @@ func TestAdminAuthMiddleware_DBErrorIsNotUnauthorized(t *testing.T) {
t.Fatalf("Close: %v", err)
}
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
+10 -10
View File
@@ -49,7 +49,7 @@ func createRoleUser(t *testing.T, database *db.DB, roleID int64, name string, pe
func newModeratorHandler(t *testing.T) (http.Handler, *db.DB, string) {
t.Helper()
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
return handler, database, token
}
@@ -69,7 +69,7 @@ func TestPerimeter_ModeratorAdmitted(t *testing.T) {
func TestPerimeter_NoModerationBitsRejected(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// MANAGE_MESSAGES alone is not a perimeter bit — it has no admin route.
_, token := createRoleUser(t, database, 11, "Helper", permissions.ManageMessages, 50, "helperuser")
@@ -129,7 +129,7 @@ func TestPatchUserBan_ModeratorAllowed(t *testing.T) {
func TestChannelRoutes_WithoutManageChannelsForbidden(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, token := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser")
for _, tc := range []struct {
@@ -170,7 +170,7 @@ func TestAuditAndSettings_ModeratorForbidden(t *testing.T) {
func TestAuditAndSettings_BitHoldersAllowed(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, auditToken := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser")
_, cfgToken := createRoleUser(t, database, 13, "Configurator", permissions.ManageServer, 50, "cfguser")
@@ -215,7 +215,7 @@ func TestOwnerOnlyRoutes_ModeratorForbidden(t *testing.T) {
func TestForceLogout_RequiresKickMembers(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, token := createRoleUser(t, database, 14, "ChannelMod", permissions.ManageChannels, 60, "chanmoduser")
targetUID, _ := database.CreateUser(context.Background(), "victim", "hash", 3)
@@ -235,7 +235,7 @@ func TestForceLogout_RequiresKickMembers(t *testing.T) {
func TestForceLogout_HierarchyEnforced(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
// Owner (role 1, position 100) outranks the moderator.
@@ -269,7 +269,7 @@ func TestForceLogout_HierarchyEnforced(t *testing.T) {
func TestPatchUserRole_RequiresManageRoles(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// The seeded Moderator mask stops at bit 19 — no MANAGE_ROLES (bit 24).
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
targetUID, _ := database.CreateUser(context.Background(), "promoteme", "hash", 3)
@@ -286,7 +286,7 @@ func TestPatchUserRole_RequiresManageRoles(t *testing.T) {
func TestPatchUserRole_CannotPromoteToOwner(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Role 2 "Admin" (position 80) holds MANAGE_ROLES but is below Owner.
_, token := createRoleUser(t, database, 2, "Admin", 0x3FFFFFFF, 80, "adminuser2")
targetUID, _ := database.CreateUser(context.Background(), "wannabeowner", "hash", 3)
@@ -304,7 +304,7 @@ func TestPatchUserRole_CannotPromoteToOwner(t *testing.T) {
func TestPatchUserRole_ModeratorCannotDemoteAdmin(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// A moderator that does hold MANAGE_ROLES still cannot touch a higher rank.
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask|permissions.ManageRoles, 60, "moduser")
adminUID, err := database.CreateUser(context.Background(), "sitting-admin", "hash", 2)
@@ -383,7 +383,7 @@ func TestGetMe_ReportsCallerPermissions(t *testing.T) {
func TestGetMe_OwnerFlagged(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
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, "/me", token, nil)
+3 -3
View File
@@ -131,7 +131,7 @@ func TestApplyUpdate_Conflict409(t *testing.T) {
t.Setenv("OWNCORD_CONTAINER", "0")
database := openAdminTestDB(t)
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
cases := []struct {
@@ -165,7 +165,7 @@ func TestApplyUpdate_Conflict409(t *testing.T) {
// POST /backups/{name}/restore refuses the same way — before any disk I/O.
func TestRestoreBackup_Conflict409(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
admin.ForceRestartState(false) // restart pending
@@ -190,7 +190,7 @@ func TestRestoreBackup_Conflict409(t *testing.T) {
func TestRestore_CloseFailure_StillMarksPendingAndRequestsRestart(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
backupDir := filepath.Join(tmpDir, "data", "backups")
+1 -1
View File
@@ -34,7 +34,7 @@ func TestSetup_HonoursTrustedProxies(t *testing.T) {
cfg.Server.TrustedProxies = []string{trustedProxyAddr + "/32"}
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil,
newTestModService(database), newTestRoleService(database), newTestSettingsService(database),
newTestModService(database), newTestRoleService(database),
admin.SetupOptions{RunningCfg: cfg})
body, err := json.Marshal(map[string]string{
+10 -10
View File
@@ -14,7 +14,7 @@ import (
func TestSetupStatus_NeedsSetup(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
if rr.Code != http.StatusOK {
@@ -35,7 +35,7 @@ func TestSetupStatus_NeedsSetup(t *testing.T) {
func TestSetupStatus_NoSetupNeeded(t *testing.T) {
database := openAdminTestDB(t)
createAdminUser(t, database) // Create a user first
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
if rr.Code != http.StatusOK {
@@ -55,7 +55,7 @@ func TestSetupStatus_NoSetupNeeded(t *testing.T) {
func TestSetup_CreatesOwner(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "myadmin",
@@ -108,7 +108,7 @@ func TestSetup_CreatesOwner(t *testing.T) {
// Server/api/auth_handler_test.go (TestRegister_UsernameNotHTMLEscaped).
func TestSetup_UsernameNotHTMLEscaped(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "O'Brien",
@@ -138,7 +138,7 @@ func TestSetup_UsernameNotHTMLEscaped(t *testing.T) {
func TestSetup_BlockedAfterFirstUser(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// First setup succeeds.
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
@@ -161,7 +161,7 @@ func TestSetup_BlockedAfterFirstUser(t *testing.T) {
func TestSetup_WeakPassword(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "admin",
@@ -174,7 +174,7 @@ func TestSetup_WeakPassword(t *testing.T) {
func TestSetup_MissingFields(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "",
@@ -189,7 +189,7 @@ func TestSetup_MissingFields(t *testing.T) {
// server and asserts that exactly one owner is created (BUG-119).
func TestSetup_ConcurrentRace(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
const goroutines = 20
results := make(chan int, goroutines)
@@ -242,7 +242,7 @@ func TestSetup_ConcurrentRace(t *testing.T) {
// on an empty allowlist, and a foreign origin still does not.
func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"})
if err != nil {
@@ -264,7 +264,7 @@ func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) {
func TestSetup_ForeignOriginStillBlocked(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"})
if err != nil {
+1 -1
View File
@@ -29,7 +29,7 @@ func TestSetupLimiter_ReapsStaleEntries(t *testing.T) {
defer restoreHook()
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
if limiter == nil {
t.Fatal("setup limiter was not captured — CaptureSetupLimiter hook not wired into NewAdminAPI")
+2 -2
View File
@@ -32,7 +32,7 @@ func TestSetup_SessionCreationFailureDoesNotOrphanOwner(t *testing.T) {
t.Fatalf("DROP TABLE sessions: %v", err)
}
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "owner1",
@@ -82,7 +82,7 @@ func TestSetup_InviteCreationFailureDoesNotOrphanOwner(t *testing.T) {
t.Fatalf("DROP TABLE invites: %v", err)
}
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
"username": "owner2",
+1 -1
View File
@@ -40,7 +40,7 @@ func wizardRunningCfg() *config.Config {
func wizardHandler(t *testing.T, database *db.DB, cfgPath string, restarted chan string) http.Handler {
t.Helper()
t.Cleanup(admin.ResetRestartState)
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database),
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database),
admin.SetupOptions{
ConfigPath: cfgPath,
RunningCfg: wizardRunningCfg(),
+16
View File
@@ -26,6 +26,22 @@ const (
adminTokenHashKey
)
// ─── Allowed settings keys ────────────────────────────────────────────────────
// allowedSettingKeys is the whitelist of keys that may be written via
// PATCH /admin/api/settings. Derived from the settings table in SCHEMA.md.
var allowedSettingKeys = map[string]struct{}{
"server_name": {},
"server_icon": {},
"motd": {},
"max_upload_bytes": {},
"voice_quality": {},
"require_2fa": {},
"registration_open": {},
"backup_schedule": {},
"backup_retention": {},
}
// ─── HubBroadcaster ──────────────────────────────────────────────────────────
// HubBroadcaster is the subset of ws.Hub needed by the admin package.
+14 -14
View File
@@ -39,7 +39,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
@@ -78,7 +78,7 @@ func TestAdminAPI_CheckUpdate_IncompleteReleaseNotInstallable(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
@@ -111,7 +111,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
@@ -128,7 +128,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodGet, "/updates", "", nil)
if w.Code != http.StatusUnauthorized {
@@ -138,7 +138,7 @@ func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) {
func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Create admin user (not owner - role 2)
adminUID, _ := database.CreateUser(context.Background(), "adminonly2", "hash", 2)
@@ -158,7 +158,7 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) {
func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) {
database := openAdminTestDB(t)
// nil updater — the endpoint should return 503
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -171,7 +171,7 @@ func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) {
// in the 503 response.
func TestAdminAPI_ApplyUpdate_NilUpdater_ErrorCode(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -203,7 +203,7 @@ func TestAdminAPI_ApplyUpdate_NoUpdateAvailable(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -234,7 +234,7 @@ func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -262,7 +262,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -283,7 +283,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) {
// unauthenticated requests to POST /updates/apply.
func TestAdminAPI_ApplyUpdate_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
w := doRequest(t, handler, http.MethodPost, "/updates/apply", "", nil)
if w.Code != http.StatusUnauthorized {
@@ -350,7 +350,7 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) {
// the important thing is that the code path is executed.
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -371,7 +371,7 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) {
func TestAdminAPI_ApplyUpdate_RefusedInContainer(t *testing.T) {
t.Setenv("OWNCORD_CONTAINER", "1")
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -393,7 +393,7 @@ func TestAdminAPI_ApplyUpdate_RefusedInContainer(t *testing.T) {
func TestAdminAPI_ApplyUpdate_ContainerOptOut(t *testing.T) {
t.Setenv("OWNCORD_CONTAINER", "0")
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
+1 -4
View File
@@ -56,10 +56,7 @@ func fullRouter(t *testing.T) http.Handler {
GIF: config.GIFConfig{APIKey: "absence-test"},
}
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
return handler
+2 -8
View File
@@ -36,10 +36,7 @@ func setupDiagnosticsRouter(t *testing.T) (http.Handler, string, *db.DB) {
},
}
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil, rt)
t.Cleanup(cleanup)
@@ -111,10 +108,7 @@ func TestDiagnosticsConnectivity_HonoursTrustedProxies(t *testing.T) {
},
}
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil, rt)
t.Cleanup(cleanup)
+3 -24
View File
@@ -15,10 +15,7 @@ import (
"google.golang.org/protobuf/proto"
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/service"
"github.com/J3vb/OwnCord/Server/ws"
)
@@ -241,6 +238,7 @@ func hubWithLiveKit(t *testing.T, status int) *ws.Hub {
}))
t.Cleanup(srv.Close)
hub := ws.NewHub(nil, nil, nil)
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
LiveKitAPIKey: "testkeytestkeytest",
LiveKitAPISecret: "testsecrettestsecrettestsecret",
@@ -249,26 +247,7 @@ func hubWithLiveKit(t *testing.T, status int) *ws.Hub {
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
hub := newBareHub(t, lk)
return hub
}
// newBareHub builds the smallest hub NewHub now accepts: B3-4 made DB and
// Limiter required, so the pre-B3-4 ws.NewHub(nil, nil, nil) fixture — the
// poster child of construction succeeding with nothing wired — is illegal by
// design. An unmigrated in-memory database is enough: construction only
// best-effort-reads the settings cache.
func newBareHub(t *testing.T, lk *ws.LiveKitClient) *ws.Hub {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
hub, err := ws.NewHub(ws.HubOptions{DB: database, Limiter: auth.NewRateLimiter(), LiveKit: lk, Settings: service.NewSettingsService(database)})
if err != nil {
t.Fatalf("ws.NewHub: %v", err)
}
hub.SetLiveKit(lk)
return hub
}
@@ -324,7 +303,7 @@ func TestHandleLiveKitHealth_Degraded(t *testing.T) {
func TestHandleLiveKitHealth_NotConfigured(t *testing.T) {
// A hub with no LiveKit client at all — the common case when voice is off.
handler := api.LiveKitHealthHandlerForTest(newBareHub(t, nil))
handler := api.LiveKitHealthHandlerForTest(ws.NewHub(nil, nil, nil))
rr := httptest.NewRecorder()
handler(rr, httptest.NewRequest(http.MethodGet, "/api/v1/livekit/health", nil))
+1 -1
View File
@@ -187,7 +187,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
// Admin panel: static files + REST API (Phase 6).
// Restrict /admin to configured CIDRs (default: private networks only).
u := updater.NewUpdater(ver, cfg.GitHub.Token, cfg.GitHub.Owner, cfg.GitHub.Repo)
adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, svc.Roles, svc.Settings,
adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, svc.Roles,
admin.SetupOptions{ConfigPath: config.DefaultPath, RunningCfg: cfg})
r.Group(func(r chi.Router) {
r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies))
@@ -98,10 +98,7 @@ func TestNewRouter_DeleteAccount_BroadcastsMemberBanOverWS(t *testing.T) {
},
}
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
+1 -4
View File
@@ -73,10 +73,7 @@ func TestNewRouter_LiveKitProcessStartFailure_VoiceJoinFailsClosed(t *testing.T)
},
}
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
+1 -4
View File
@@ -33,10 +33,7 @@ func setupRouter(t *testing.T) http.Handler {
},
}
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
return handler
+1 -7
View File
@@ -43,12 +43,6 @@ func TestNewRouterRefusesToStartWithMalformedTOTPKey(t *testing.T) {
},
}
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
defer rt.Hub.GracefulStop()
panicked := false
func() {
defer func() {
@@ -56,7 +50,7 @@ func TestNewRouterRefusesToStartWithMalformedTOTPKey(t *testing.T) {
panicked = true
}
}()
api.NewRouter(cfg, database, "test", nil, nil, rt)
api.NewRouter(cfg, database, "test", nil, nil, app.StartRuntime(cfg, database, nil))
}()
if !panicked {
+1 -4
View File
@@ -256,10 +256,7 @@ func genRoutes(w io.Writer) error {
// internal/app owns hub construction since B3-3, so the route index is
// generated over the same collaborators the server runs with.
rt, err := app.StartRuntime(cfg, database, nil)
if err != nil {
return fmt.Errorf("building runtime for the route walk: %w", err)
}
rt := app.StartRuntime(cfg, database, nil)
defer rt.Hub.GracefulStop()
handler, cleanup := api.NewRouter(cfg, database, "gendocs", nil, nil, rt)
defer cleanup()
+1 -1
View File
@@ -6,7 +6,7 @@
"auth": 90.8,
"db": 79.3,
"permissions": 100.0,
"service": 69.2,
"service": 67.8,
"ws": 86.7
}
}
-26
View File
@@ -5,10 +5,8 @@ import (
"database/sql"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"github.com/J3vb/OwnCord/Server/db/dbgen"
@@ -325,30 +323,6 @@ func (d *DB) SetSetting(ctx context.Context, key, value string) error {
return nil
}
// ApplySettings upserts every key→value pair in one transaction, so a
// mid-loop failure leaves no partial update behind. Keys are applied in
// sorted order for deterministic behaviour under test.
func (d *DB) ApplySettings(ctx context.Context, updates map[string]string) error {
if len(updates) == 0 {
return nil
}
tx, err := d.writer.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ApplySettings: begin: %w", err)
}
q := d.q.WithTx(tx)
for _, key := range slices.Sorted(maps.Keys(updates)) {
if err := q.SetSetting(ctx, dbgen.SetSettingParams{Key: key, Value: updates[key]}); err != nil {
_ = tx.Rollback()
return fmt.Errorf("ApplySettings: %s: %w", key, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ApplySettings: commit: %w", err)
}
return nil
}
// GetAllSettings returns all settings as a key→value map.
func (d *DB) GetAllSettings(ctx context.Context) (map[string]string, error) {
rows, err := d.q.GetAllSettings(ctx)
-58
View File
@@ -1,58 +0,0 @@
package db_test
import (
"context"
"testing"
)
// ApplySettings targets the real settings table, so these tests use the
// package's full-migration opener (migrated_db_test.go), not the minimal
// shared testSchema fixture.
func TestApplySettings_AppliesEveryKey(t *testing.T) {
database := newMigratedTestDB(t)
if err := database.ApplySettings(context.Background(), map[string]string{
"server_name": "Applied",
"motd": "Hello",
}); err != nil {
t.Fatalf("ApplySettings: %v", err)
}
for key, want := range map[string]string{"server_name": "Applied", "motd": "Hello"} {
got, err := database.GetSetting(context.Background(), key)
if err != nil || got != want {
t.Fatalf("GetSetting(%s) = %q, %v; want %q", key, got, err, want)
}
}
}
func TestApplySettings_EmptyMapIsNoOp(t *testing.T) {
database := newMigratedTestDB(t)
if err := database.ApplySettings(context.Background(), nil); err != nil {
t.Fatalf("ApplySettings(nil): %v", err)
}
}
func TestApplySettings_RollsBackOnFailure(t *testing.T) {
database := newMigratedTestDB(t)
// Sabotage the table so the in-transaction upsert fails after Begin
// succeeded — the rollback path must surface the error, and a later
// repair must find no half-applied state (the transaction is the unit).
if _, err := database.ExecContext(context.Background(), "DROP TABLE settings"); err != nil {
t.Fatalf("DROP TABLE settings: %v", err)
}
if err := database.ApplySettings(context.Background(), map[string]string{
"server_name": "never",
}); err == nil {
t.Fatal("ApplySettings against a dropped table must error")
}
}
func TestApplySettings_BeginFailsOnClosedDB(t *testing.T) {
database := newMigratedTestDB(t)
_ = database.Close()
if err := database.ApplySettings(context.Background(), map[string]string{
"server_name": "never",
}); err == nil {
t.Fatal("ApplySettings on a closed database must error")
}
}
+54 -64
View File
@@ -1,7 +1,6 @@
package app
import (
"fmt"
"log/slog"
"net/url"
@@ -14,18 +13,16 @@ import (
"github.com/J3vb/OwnCord/Server/ws"
)
// StartRuntime builds the collaborators the hub and the router share,
// constructs the hub with everything it must hold before Run (B3-4:
// HubOptions replaced the pre-Run setters), and starts the dispatch
// goroutine.
// StartRuntime builds the collaborators the hub and the router share, applies
// every pre-Run hub setter, and starts the hub's dispatch goroutine.
//
// Before B3-3 this lived inside api.NewRouter, while main.go set the event
// persister and store after NewRouter returned — two owners of one hub, with
// nothing checking that the required collaborators were present before Run
// started. B3-3 collapsed the owners to one; B3-4 moved the pre-Run wiring
// into ws.NewHub itself, which now refuses to construct without its required
// collaborators, so an incomplete hub is a startup error here rather than a
// later panic.
// Before B3-3 this lived inside api.NewRouter (the ws.NewHub call at
// router.go:106 and the plugin and LiveKit setters at :325-360), while
// main.go set the event persister and the event store after NewRouter
// returned — two owners of one hub, with nothing checking that the required
// collaborators were present before Run started. There is one owner now, and
// one place B3-4 has to change when the required setters become validated
// constructor options.
//
// The limiter and the service layer are built here rather than in the router
// because the hub needs the SAME instances: the limiter persists auth
@@ -35,82 +32,75 @@ import (
// It starts the hub, so every caller must stop it — App.Close does, through
// the "hub" close step; api's tests rely on the goleak ignore for
// ws.(*Hub).Run.func1 exactly as they did when NewRouter started it.
func StartRuntime(cfg *config.Config, database *db.DB, pluginRegistry *plugin.Registry) (api.Runtime, error) {
func StartRuntime(cfg *config.Config, database *db.DB, pluginRegistry *plugin.Registry) api.Runtime {
// Lockouts are persisted to the database so they survive restarts (M2).
limiter := auth.NewPersistentRateLimiter(database)
// Service layer — centralises business logic for REST and WS handlers.
// *db.DB satisfies service.Store directly.
svc := service.New(database, limiter)
lk, proc, voiceEnabled := buildVoice(cfg)
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware.
hub, err := ws.NewHub(ws.HubOptions{
DB: database,
Limiter: limiter,
Services: svc,
Settings: svc.Settings,
// nil pluginRegistry means plugins are disabled; the hub no-ops.
PluginRegistry: pluginRegistry,
LiveKit: lk,
LiveKitProcess: proc,
// Replay budget knobs land at construction — the dispatch loop
// reads the ring unlocked.
ReplayRingSize: cfg.EventPersistence.ReplayRingSize,
ReplayColdLimit: cfg.EventPersistence.ReplayColdLimit,
})
if err != nil {
return api.Runtime{}, fmt.Errorf("app: building hub: %w", err)
}
hub := ws.NewHub(database, limiter, svc)
// Replay budget knobs must land before hub.Run starts (below).
hub.ConfigureReplay(cfg.EventPersistence.ReplayRingSize, cfg.EventPersistence.ReplayColdLimit)
// The plugin event sink consumes the built hub's broadcaster, so it is
// the surviving two-phase wire (moved from api.routerPluginWiring).
wirePlugins(hub, pluginRegistry)
voiceEnabled := startVoice(cfg, hub)
go hub.Run()
return api.Runtime{Hub: hub, Limiter: limiter, Services: svc, VoiceEnabled: voiceEnabled}
}
// wirePlugins wires the plugin registry and its event sink into the hub.
// Moved from api.routerPluginWiring.
func wirePlugins(hub *ws.Hub, pluginRegistry *plugin.Registry) {
// Phase C Step 9 — wire plugin registry and event sink into the hub.
// nil pluginRegistry means plugins are disabled; the hub no-ops cleanly.
if pluginRegistry != nil {
hub.SetPluginRegistry(pluginRegistry)
sink := pluginRegistry.Sink()
sink.SetBroadcaster(hub.BroadcastToChannel)
hub.SetPluginEventSink(sink)
}
// Start the supervised LiveKit process only once the hub holds it
// (OC-0019): the voice_join guard must be able to fail closed via
// IsRunning() == false the moment Start fails, never see a half-wired
// hub with a running process it does not know about.
if proc != nil {
if startErr := proc.Start(); startErr != nil {
slog.Error("failed to start LiveKit process", "error", startErr)
}
}
go hub.Run()
return api.Runtime{Hub: hub, Limiter: limiter, Services: svc, VoiceEnabled: voiceEnabled}, nil
}
// buildVoice creates the LiveKit client and, when OwnCord manages the
// companion process, the process manager — construction only; StartRuntime
// starts the process after the hub holds it. It reports whether voice is
// startVoice creates the LiveKit client and, when OwnCord manages the
// companion process, starts it — the construction half of what
// api.routerVoiceRoutes did before B3-3. It reports whether voice is
// configured; the webhook, LiveKit health and signalling-proxy routes are
// still mounted by the router on exactly that condition (the `lkErr == nil`
// still mounted by the router, on exactly that condition (the `lkErr == nil`
// guard, now api.Runtime.VoiceEnabled).
func buildVoice(cfg *config.Config) (*ws.LiveKitClient, *ws.LiveKitProcess, bool) {
func startVoice(cfg *config.Config, hub *ws.Hub) bool {
// Create LiveKit client if voice config is present; voice is disabled on failure.
lk, lkErr := ws.NewLiveKitClient(&cfg.Voice)
if lkErr != nil {
slog.Warn("failed to create LiveKit client, voice disabled", "error", lkErr)
return nil, nil, false
return false
}
hub.SetLiveKit(lk)
// Optionally build a companion LiveKit process — either from a
// Optionally start a companion LiveKit process — either from a
// configured binary or via checksum-verified auto-download (the
// download happens in the background inside Start). The hub keeps the
// process even if Start() later fails (OC-0019): its only hub consumer
// is the voice_join guard (`h.lkProcess != nil && !h.lkProcess.IsRunning()`),
// which reads a nil process as "LiveKit is externally managed, don't
// check". OwnCord being told to manage LiveKit and failing to launch it
// must fail joins closed via IsRunning() == false, not wave them
// through with no SFU running.
// download happens in the background inside Start).
if cfg.Voice.LiveKitBinaryPath != "" || cfg.Voice.AutoDownloadLiveKit {
return lk, ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir), true
proc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir)
// Register the process with the hub BEFORE calling Start(), and
// keep it registered even if Start() fails (OC-0019). The only
// consumer of h.lkProcess is the voice_join guard
// (`h.lkProcess != nil && !h.lkProcess.IsRunning()`), which reads
// a nil process as "LiveKit is externally managed, don't check".
// That is the wrong reading here: OwnCord was told to manage
// LiveKit and failed to launch it, so joins must fail closed via
// IsRunning() == false, not be waved through with no SFU
// running. IsRunning() is false for a proc whose Start() never
// got as far as spawning cmd, and Hub.Stop's lkProcess.Stop() is
// safe to call on a never-started proc.
hub.SetLiveKitProcess(proc)
if startErr := proc.Start(); startErr != nil {
slog.Error("failed to start LiveKit process", "error", startErr)
}
return true
}
// Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs.
@@ -123,5 +113,5 @@ func buildVoice(cfg *config.Config) (*ws.LiveKitClient, *ws.LiveKitProcess, bool
"add the LiveKit server's IP to livekit_webhook_allowed_cidrs or webhooks will be silently dropped",
"livekit_host", lkHost)
}
return lk, nil, true
return true
}
+5 -11
View File
@@ -198,11 +198,9 @@ func (a *App) startPlugins() error {
return nil
}
// startHub builds the hub and the collaborators it shares with the router
// and starts the dispatch goroutine — B3-3 moved all of that out of
// api.NewRouter so the hub has exactly one owner, and B3-4 moved the pre-Run
// wiring into ws.HubOptions, so an incomplete hub fails this start step
// instead of panicking later.
// startHub builds the hub and the collaborators it shares with the router,
// applies every pre-Run setter and starts the dispatch goroutine — B3-3 moved
// all of that out of api.NewRouter so the hub has exactly one owner.
//
// Its close step is GracefulStopContext, the only caller of
// LiveKitProcess.Stop and what closes the dispatch goroutine. gracefulOnce
@@ -210,11 +208,7 @@ func (a *App) startPlugins() error {
// path, so it is reached on every return from Run and a supervised
// livekit-server process is never orphaned (OC-0027).
func (a *App) startHub() error {
rt, err := StartRuntime(a.cfg, a.database, a.plugins)
if err != nil {
return err
}
a.runtime = rt
a.runtime = StartRuntime(a.cfg, a.database, a.plugins)
a.hub = a.runtime.Hub
a.onClose("hub", func(ctx context.Context) error {
a.runtime.Hub.GracefulStopContext(ctx)
@@ -266,7 +260,7 @@ func (a *App) startAuditWriter() error {
// in-flight tick (which can hold the writer — scheduled backups run VACUUM
// INTO) is not still using the database when the handle closes.
func (a *App) startMaintenance() error {
stop := startMaintenanceLoop(a.bgCtx, a.log, a.cfg, a.database, a.runtime.Services.Settings)
stop := startMaintenanceLoop(a.bgCtx, a.log, a.cfg, a.database)
a.onClose("maintenance", func(context.Context) error {
stop()
return nil
+3 -13
View File
@@ -18,7 +18,6 @@ import (
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/service"
"github.com/J3vb/OwnCord/Server/ws"
)
@@ -116,10 +115,7 @@ func TestSeedHubReplayState_ForcesFullResyncForOfflineClient(t *testing.T) {
}
limiter := auth.NewRateLimiter()
hub, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
hub := ws.NewHub(database, limiter, nil)
go hub.Run()
defer hub.Stop()
@@ -264,10 +260,7 @@ func TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync(t *t
// last_seq ends up as whatever the 40th broadcast's real seq turns out to
// be — captured dynamically so this test holds regardless of what value
// scheme is in effect (raw 1..N pre-fix, or a seeded floor post-fix). ---
hubOld, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
hubOld := ws.NewHub(database, limiter, nil)
go hubOld.Run()
if persister, prunerDone := startEventPersister(ctx, log, cfg, hubOld, database); persister != nil || prunerDone != nil {
t.Fatalf("startEventPersister with Enabled=false: want (nil, nil), got (%v, %v)", persister, prunerDone)
@@ -282,10 +275,7 @@ func TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync(t *t
// --- Restart: hub B is a brand-new process-equivalent hub, same disabled
// config, same (in-memory but never touched by persistence) database. ---
hubNew, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
hubNew := ws.NewHub(database, limiter, nil)
go hubNew.Run()
defer hubNew.Stop()
if persister, prunerDone := startEventPersister(ctx, log, cfg, hubNew, database); persister != nil || prunerDone != nil {
+6 -7
View File
@@ -8,13 +8,12 @@ import (
"github.com/J3vb/OwnCord/Server/admin"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/service"
"github.com/J3vb/OwnCord/Server/storage"
)
// startMaintenanceLoop starts the periodic maintenance loop and returns the
// stop step the maintenance stage registers with App.Close.
func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.Config, database *db.DB, settings *service.SettingsService) func() {
func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.Config, database *db.DB) func() {
// Periodically purge expired sessions and orphaned attachments.
fileStorage, fileStorageErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB)
if fileStorageErr != nil {
@@ -23,7 +22,7 @@ func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.C
stopMaintenance := make(chan struct{})
maintenanceDone := make(chan struct{})
go maintenanceLoop(bgCtx, log, database, fileStorage, settings, stopMaintenance, maintenanceDone)
go maintenanceLoop(bgCtx, log, database, fileStorage, stopMaintenance, maintenanceDone)
return func() {
// Backstop for early returns below (see hub.GracefulStop defer above),
@@ -41,7 +40,7 @@ func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.C
// maintenanceLoop is the periodic maintenance goroutine started by
// startMaintenanceLoop.
func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage, settings *service.SettingsService, stopMaintenance, maintenanceDone chan struct{}) {
func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage, stopMaintenance, maintenanceDone chan struct{}) {
defer close(maintenanceDone)
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
@@ -58,7 +57,7 @@ func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, f
continue
}
if maintenanceTick(bgCtx, log, database, fileStorage, settings) {
if maintenanceTick(bgCtx, log, database, fileStorage) {
consecutiveFailures++
} else {
consecutiveFailures = 0
@@ -71,7 +70,7 @@ func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, f
// maintenanceTick runs one maintenance pass and reports whether any step
// of it failed.
func maintenanceTick(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage, settings *service.SettingsService) bool {
func maintenanceTick(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage) bool {
tickFailed := false
if err := database.DeleteExpiredSessions(bgCtx); err != nil {
log.Warn("failed to delete expired sessions", "error", err)
@@ -80,7 +79,7 @@ func maintenanceTick(bgCtx context.Context, log *slog.Logger, database *db.DB, f
// Scheduled backups + retention pruning, driven by the
// backup_schedule / backup_retention admin settings.
if err := admin.MaintainBackups(bgCtx, database, settings); err != nil {
if err := admin.MaintainBackups(bgCtx, database); err != nil {
log.Warn("backup maintenance failed", "error", err)
tickFailed = true
}
+7 -10
View File
@@ -41,11 +41,12 @@ var DBImportAllow = map[string]DBImportEntry{
// ── admin ─────────────────────────────────────────────────────────────
"admin/admin.go": {"boundary", "", "holds the handle for the admin mux; no calls"},
"admin/api.go": {"boundary", "", "passes the handle to handlers; no calls"},
"admin/backup_maintenance.go": {"boundary", "", "scheduled backup mechanics on the maintenance tick; settings via the service"},
"admin/handlers_backup.go": {"boundary", "", "backup create/list/delete/restore owns the handle: VACUUM INTO, WAL checkpoint, close-and-swap"},
"admin/backup_maintenance.go": {"move", "settings-ops", "BackupToSafe, integrity check, settings reads"},
"admin/handlers_backup.go": {"move", "settings-ops", "backup trigger and download; raw SQLDb for VACUUM INTO"},
"admin/handlers_channel_perms.go": {"move", "channel", "override CRUD decides permission policy in the handler"},
"admin/handlers_channels.go": {"move", "channel", "channel CRUD + audit"},
"admin/handlers_roles.go": {"move", "role", "two reads; service/role.go already owns the writes"},
"admin/handlers_settings.go": {"move", "settings-ops", "BeginTx in a handler; TOTP census"},
"admin/handlers_tokens.go": {"move", "auth", "API-token CRUD duplicated in token_cli.go"},
"admin/handlers_users.go": {"move", "user", "user list, stats, lookups"},
"admin/helpers.go": {"adapter", "", "Role/User types in response helpers"},
@@ -90,18 +91,14 @@ var DBImportAllow = map[string]DBImportEntry{
"ws/eventstore.go": {"adapter", "", "PersistedEvent type; store is an interface"},
"ws/handlers.go": {"move", "channel", "channel, role, session-ban and DM reads in command handlers"},
"ws/handlers_chat.go": {"adapter", "", "pure NewDMChannelInfo helper"},
"ws/hub.go": {"boundary", "", "Hub state holds the handle the families read through; no calls"},
"ws/hub_options.go": {"boundary", "", "construction validates and stores the handle; no calls"},
"ws/hub_broadcast.go": {"move", "channel", "member broadcast payloads read the user and role they announce"},
"ws/hub_presence.go": {"adapter", "", "presence coalescer; pure BroadcastStatus helper and the MemberSummary shape"},
"ws/hub_visibility.go": {"move", "channel", "visibility and audience resolution reads channels, overrides, participants, users"},
"ws/hub.go": {"move", "settings-ops", "GetSetting at construction"},
"ws/hub_broadcast.go": {"move", "channel", "visibility refresh reads channels, roles, users"},
"ws/hub_sweep.go": {"move", "voice", "stale-voice sweep reads and leaves"},
"ws/messages.go": {"adapter", "", "wire types + pure status helpers"},
"ws/replay.go": {"move", "connection", "reconnect replay selection and delivery; serve.go's row split with its code in B3-5"},
"ws/serve.go": {"move", "connection", "connect/disconnect lifecycle; B3-5 splits it by family first"},
"ws/serve_auth.go": {"move", "auth", "handshake auth: session, user and role lookups, connect audit, failed-handshake teardown"},
"ws/serve_auth.go": {"move", "auth", "session lookup on the WebSocket handshake"},
"ws/serve_pumps.go": {"move", "user", "MarkUserDisconnected on pump exit"},
"ws/serve_ready.go": {"move", "channel", "ready snapshot and fresh-connect: channels, overrides, unreads, DMs, members, stale-voice cleanup"},
"ws/serve_ready.go": {"move", "channel", "ready snapshot: channels, overrides, unreads, DMs, members"},
"ws/voice_join.go": {"move", "voice", "voice state reads and writes"},
"ws/voice_moderation.go": {"move", "voice", "mute/deafen/move persist voice state"},
}
-3
View File
@@ -201,7 +201,4 @@ type Store interface {
GetSetting(ctx context.Context, key string) (string, error)
SetSetting(ctx context.Context, key, value string) error
GetAllSettings(ctx context.Context) (map[string]string, error)
// ApplySettings upserts every pair in one transaction — the settings
// PATCH is atomic from the caller's perspective (settings family).
ApplySettings(ctx context.Context, updates map[string]string) error
}
-2
View File
@@ -22,7 +22,6 @@ type Services struct {
Moderation *ModerationService
Roles *RoleService
Emoji *EmojiService
Settings *SettingsService
}
// New creates all domain services wired together.
@@ -40,6 +39,5 @@ func New(st Store, limiter *auth.RateLimiter) *Services {
Moderation: NewModerationService(st, permSvc),
Roles: NewRoleService(st, permSvc),
Emoji: NewEmojiService(st, permSvc),
Settings: NewSettingsService(st),
}
}
-175
View File
@@ -1,175 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/J3vb/OwnCord/Server/db"
)
// SettingsService owns the server-settings policy the admin panel writes and
// the rest of the server reads: which keys exist, how boolean values are
// normalized, the require_2fa preconditions, and the atomic apply. Handlers
// are thin adapters over it (B3-8 settings/audit family); the hub reads
// server identity through it instead of the raw handle.
type SettingsService struct {
st Store
}
// NewSettingsService creates a SettingsService.
func NewSettingsService(st Store) *SettingsService {
return &SettingsService{st: st}
}
// allowedSettingKeys is the whitelist of keys that may be written via the
// admin settings PATCH. Derived from the settings table in SCHEMA.md. The
// policy lives here so no handler can grow its own copy.
var allowedSettingKeys = map[string]struct{}{
"server_name": {},
"server_icon": {},
"motd": {},
"max_upload_bytes": {},
"voice_quality": {},
"require_2fa": {},
"registration_open": {},
"backup_schedule": {},
"backup_retention": {},
}
// List returns every setting as a key→value map.
func (s *SettingsService) List(ctx context.Context) (map[string]string, error) {
return s.st.GetAllSettings(ctx)
}
// Setting returns one setting's value. Errors wrap db.ErrNotFound for a
// missing key, exactly as the store reports it — the hub's identity reads
// and the backup scheduler both branch on that.
func (s *SettingsService) Setting(ctx context.Context, key string) (string, error) {
return s.st.GetSetting(ctx, key)
}
// Patch validates, normalizes and atomically applies updates, then writes
// one audit row per changed key attributed to actorID. The returned map is
// the full settings table after the apply (the admin panel re-renders from
// it). Validation failures return ErrBadRequest with the reason; nothing is
// written unless every key passes.
func (s *SettingsService) Patch(ctx context.Context, actorID int64, updates map[string]string) (map[string]string, error) {
// Validate all keys against the whitelist before writing anything so the
// operation is atomic from the caller's perspective. The %.0w verb wraps
// ErrBadRequest without adding its text: the admin surface's response
// bodies are pinned to exactly these messages, prefix-free.
for key := range updates {
if _, ok := allowedSettingKeys[key]; !ok {
return nil, fmt.Errorf("unknown setting key: %q%.0w", key, ErrBadRequest)
}
}
normalized, err := normalizeSettingUpdates(updates)
if err != nil {
return nil, fmt.Errorf("%s%.0w", err.Error(), ErrBadRequest)
}
if err := s.validateRequire2FAUpdate(ctx, normalized); err != nil {
return nil, fmt.Errorf("%s%.0w", err.Error(), ErrBadRequest)
}
if err := s.st.ApplySettings(ctx, normalized); err != nil {
return nil, fmt.Errorf("Patch: %w", err)
}
for key := range normalized {
slog.Info("setting changed", "actor_id", actorID, "key", key)
db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "setting_change", "setting", 0,
fmt.Sprintf("%s updated", key))
}
return s.st.GetAllSettings(ctx)
}
func normalizeSettingUpdates(updates map[string]string) (map[string]string, error) {
normalized := make(map[string]string, len(updates))
for key, value := range updates {
normalized[key] = value
switch key {
case "require_2fa", "registration_open":
parsed, err := parseSettingsPatchBool(value)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
if parsed {
normalized[key] = "1"
} else {
normalized[key] = "0"
}
}
}
return normalized, nil
}
func (s *SettingsService) validateRequire2FAUpdate(ctx context.Context, updates map[string]string) error {
targetRequire2FA, err := s.targetBoolSetting(ctx, updates, "require_2fa")
if err != nil {
return err
}
if !targetRequire2FA {
return nil
}
registrationOpen, err := s.targetBoolSetting(ctx, updates, "registration_open")
if err != nil {
return err
}
if registrationOpen {
return fmt.Errorf("require_2fa cannot be enabled while registration is open")
}
// The enrollment count only matters when this request is actually turning
// require_2fa on. Without this guard, an unrelated PATCH (motd, server
// name, backup settings, ...) inherits require_2fa's *current* value via
// targetBoolSetting's DB fallback and gets rejected by a precondition
// about a value it never touches — wedging the whole settings page once
// any non-banned user without TOTP exists.
if _, changingRequire2FA := updates["require_2fa"]; !changingRequire2FA {
return nil
}
count, err := s.st.CountUsersWithoutTOTP(ctx)
if err != nil {
return fmt.Errorf("failed to validate 2FA enrollment")
}
if count > 0 {
return fmt.Errorf("require_2fa cannot be enabled until all users have 2FA enabled")
}
return nil
}
func (s *SettingsService) targetBoolSetting(ctx context.Context, updates map[string]string, key string) (bool, error) {
if value, ok := updates[key]; ok {
return parseSettingsPatchBool(value)
}
value, err := s.st.GetSetting(ctx, key)
if errors.Is(err, db.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return parseSettingsPatchBool(value)
}
// parseSettingsPatchBool is auth.go's parseBooleanSettingValue with the
// admin PATCH's own error wording ("invalid boolean value", no "setting").
// Both messages are pinned by their sides' tests, so the twins stay
// separate rather than either surface changing its reply.
func parseSettingsPatchBool(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true":
return true, nil
case "0", "false":
return false, nil
default:
return false, fmt.Errorf("invalid boolean value %q", value)
}
}
-189
View File
@@ -1,189 +0,0 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/J3vb/OwnCord/Server/db"
)
// The settings family's service-level characterization (B3-8). The admin
// handler surface is pinned by admin/api_test.go's TestAdminAPI_PatchSettings_*
// rows; these tests pin the same policy at the service seam the handlers now
// delegate to, plus the contracts only the service exposes (Setting's
// ErrNotFound wrap, the audit rows, multi-key atomic apply).
func newSettingsService(t *testing.T) (*SettingsService, *db.DB) {
t.Helper()
database := newTestDB(t)
return NewSettingsService(database), database
}
func TestSettings_ListContainsMigratedDefaults(t *testing.T) {
svc, _ := newSettingsService(t)
all, err := svc.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if _, ok := all["server_name"]; !ok {
t.Fatalf("List missing server_name; got keys %v", len(all))
}
}
func TestSettings_PatchRejectsUnknownKeyWritingNothing(t *testing.T) {
svc, database := newSettingsService(t)
before, _ := database.GetSetting(context.Background(), "server_name")
_, err := svc.Patch(context.Background(), 1, map[string]string{
"server_name": "changed",
"nope": "x",
})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want ErrBadRequest", err)
}
if want := `unknown setting key: "nope"`; !strings.Contains(err.Error(), want) {
t.Fatalf("err = %q, want it to contain %q", err, want)
}
after, _ := database.GetSetting(context.Background(), "server_name")
if after != before {
t.Fatalf("server_name changed to %q despite the rejected key", after)
}
}
func TestSettings_PatchNormalizesBooleans(t *testing.T) {
svc, database := newSettingsService(t)
if _, err := svc.Patch(context.Background(), 1, map[string]string{
"registration_open": "TRUE",
}); err != nil {
t.Fatalf("Patch: %v", err)
}
got, err := database.GetSetting(context.Background(), "registration_open")
if err != nil || got != "1" {
t.Fatalf("registration_open = %q, %v; want \"1\"", got, err)
}
if _, err := svc.Patch(context.Background(), 1, map[string]string{
"registration_open": "false",
}); err != nil {
t.Fatalf("Patch: %v", err)
}
got, _ = database.GetSetting(context.Background(), "registration_open")
if got != "0" {
t.Fatalf("registration_open = %q, want \"0\"", got)
}
}
func TestSettings_PatchRejectsInvalidBoolean(t *testing.T) {
svc, _ := newSettingsService(t)
_, err := svc.Patch(context.Background(), 1, map[string]string{
"registration_open": "maybe",
})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want ErrBadRequest", err)
}
if want := `registration_open: invalid boolean value "maybe"`; !strings.Contains(err.Error(), want) {
t.Fatalf("err = %q, want it to contain %q", err, want)
}
}
func TestSettings_Require2FARejectedWhileRegistrationOpen(t *testing.T) {
svc, _ := newSettingsService(t)
_, err := svc.Patch(context.Background(), 1, map[string]string{
"require_2fa": "1",
"registration_open": "1",
})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want ErrBadRequest", err)
}
if want := "require_2fa cannot be enabled while registration is open"; !strings.Contains(err.Error(), want) {
t.Fatalf("err = %q, want it to contain %q", err, want)
}
}
func TestSettings_Require2FARejectedUntilAllEnrolled(t *testing.T) {
svc, database := newSettingsService(t)
seedUser(t, database, &db.User{ID: 1, Username: "un-enrolled"})
_, err := svc.Patch(context.Background(), 1, map[string]string{
"require_2fa": "1",
"registration_open": "0",
})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("err = %v, want ErrBadRequest", err)
}
if want := "require_2fa cannot be enabled until all users have 2FA enabled"; !strings.Contains(err.Error(), want) {
t.Fatalf("err = %q, want it to contain %q", err, want)
}
}
func TestSettings_Require2FAAllowedWithNoUnenrolledUsers(t *testing.T) {
svc, database := newSettingsService(t)
if _, err := svc.Patch(context.Background(), 1, map[string]string{
"require_2fa": "1",
"registration_open": "0",
}); err != nil {
t.Fatalf("Patch: %v", err)
}
got, _ := database.GetSetting(context.Background(), "require_2fa")
if got != "1" {
t.Fatalf("require_2fa = %q, want \"1\"", got)
}
}
func TestSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testing.T) {
svc, database := newSettingsService(t)
// require_2fa already on, and a user without TOTP exists: an unrelated
// PATCH must not inherit the gate (the wedged-settings-page regression).
if err := database.SetSetting(context.Background(), "require_2fa", "1"); err != nil {
t.Fatalf("SetSetting: %v", err)
}
seedUser(t, database, &db.User{ID: 1, Username: "un-enrolled"})
if _, err := svc.Patch(context.Background(), 1, map[string]string{"motd": "hello"}); err != nil {
t.Fatalf("unrelated Patch blocked: %v", err)
}
got, _ := database.GetSetting(context.Background(), "motd")
if got != "hello" {
t.Fatalf("motd = %q, want \"hello\"", got)
}
}
func TestSettings_PatchAppliesAllKeysAndAudits(t *testing.T) {
svc, database := newSettingsService(t)
after, err := svc.Patch(context.Background(), 7, map[string]string{
"server_name": "Renamed",
"motd": "Welcome",
})
if err != nil {
t.Fatalf("Patch: %v", err)
}
if after["server_name"] != "Renamed" || after["motd"] != "Welcome" {
t.Fatalf("returned map = %q/%q, want the applied values", after["server_name"], after["motd"])
}
entries, err := database.GetAuditLog(context.Background(), 10, 0)
if err != nil {
t.Fatalf("GetAuditLog: %v", err)
}
changes := 0
for _, e := range entries {
if e.Action == "setting_change" && e.ActorID == 7 {
changes++
}
}
if changes != 2 {
t.Fatalf("setting_change audit rows = %d, want 2", changes)
}
}
func TestSettings_SettingWrapsErrNotFound(t *testing.T) {
svc, _ := newSettingsService(t)
if _, err := svc.Setting(context.Background(), "no_such_key"); !errors.Is(err, db.ErrNotFound) {
t.Fatalf("err = %v, want db.ErrNotFound", err)
}
name, err := svc.Setting(context.Background(), "server_name")
if err != nil || name == "" {
t.Fatalf("Setting(server_name) = %q, %v", name, err)
}
}
@@ -16,6 +16,7 @@ import (
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/service"
"github.com/J3vb/OwnCord/Server/ws"
)
func seedVisibilityUser(t *testing.T, database *db.DB, username string, roleID int) *db.User {
@@ -50,7 +51,7 @@ func sortedKeys(m map[int64]bool) []int64 {
func TestChannelVisibility_RESTWSAgreement(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := newTestHubDeps(t, database, limiter, nil)
hub := ws.NewHub(database, limiter, nil)
svc := service.New(database, limiter)
// Seed one channel of each server type plus a dm channel (never visible).
@@ -176,7 +177,7 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) {
func TestChannelVisibility_UserOverrideAgreement(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := newTestHubDeps(t, database, limiter, nil)
hub := ws.NewHub(database, limiter, nil)
svc := service.New(database, limiter)
openID, err := database.CreateChannel(context.Background(), "open", "text", "", "", 0)
+3 -2
View File
@@ -82,8 +82,9 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) {
limiter := auth.NewRateLimiter()
st := database
svc := service.New(st, limiter)
hub := ws.NewHub(database, limiter, svc)
// A test LiveKit client so voice_join passes the livekit!=nil guard.
// Inject a test LiveKit client so voice_join passes the livekit!=nil guard.
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
LiveKitAPIKey: "test-api-key-12345",
LiveKitAPISecret: "test-api-secret-67890abcdef",
@@ -92,7 +93,7 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) {
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
hub := newTestHubWith(t, ws.HubOptions{DB: database, Limiter: limiter, Services: svc, LiveKit: lk})
hub.SetLiveKit(lk)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
+1 -1
View File
@@ -241,7 +241,7 @@ func TestApplySetChannelID_TransientPermissionLookupError_KeepsFocus(t *testing.
limiter := auth.NewRateLimiter()
store := &erroringOverridesStore{Store: database}
svc := service.New(store, limiter)
hub := newTestHubDeps(t, database, limiter, svc)
hub := ws.NewHub(database, limiter, svc)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
+18 -15
View File
@@ -12,14 +12,15 @@ import (
"github.com/J3vb/OwnCord/Server/ws"
)
// The registry arrives via HubOptions since B3-4; these tests wire it at
// construction.
// compile-time check that SetPluginRegistry is exported.
var _ = (*ws.Hub)(nil)
// ─── chat_command dispatch via HandleMessageForTest ───────────────────────────
// TestChatCommand_NoRegistry returns an error when no plugin registry is wired.
func TestChatCommand_NoRegistry_ReturnsError(t *testing.T) {
hub, _ := newTestHub(t)
hub, database := newTestHub(t)
_ = database
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
@@ -52,17 +53,18 @@ func TestChatCommand_NoRegistry_ReturnsError(t *testing.T) {
// TestChatCommand_UnknownCommand returns an error when the registry has no
// plugin owning the command.
func TestChatCommand_UnknownCommand_ReturnsError(t *testing.T) {
database := openTestDB(t)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub := newTestHubWith(t, ws.HubOptions{DB: database, PluginRegistry: reg})
hub, database := newTestHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub.SetPluginRegistry(reg)
raw, _ := json.Marshal(map[string]any{
"type": "chat_command",
"payload": map[string]any{
@@ -115,17 +117,18 @@ func TestChatCommand_MalformedPayload_ReturnsBadRequest(t *testing.T) {
// running DispatchCommand (and therefore the plugin's WASM invocation) once
// per frame with no cap.
func TestChatCommand_RateLimited_ReturnsError(t *testing.T) {
database := openTestDB(t)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub := newTestHubWith(t, ws.HubOptions{DB: database, PluginRegistry: reg})
hub, database := newTestHub(t)
send := make(chan []byte, 32)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub.SetPluginRegistry(reg)
sawRateLimited := false
for i := range 20 {
raw, _ := json.Marshal(map[string]any{
+1 -1
View File
@@ -79,7 +79,7 @@ func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) {
limiter := auth.NewRateLimiter()
st := database
svc := service.New(st, limiter)
hub := newTestHubDeps(t, database, limiter, svc)
hub := ws.NewHub(database, limiter, svc)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
return hub, database
+2 -2
View File
@@ -158,7 +158,7 @@ func TestComputeAllowedChannels_DMLookupErrorIsFatal(t *testing.T) {
userID, _ := database.CreateUser(context.Background(), "dm-lookup-err", "hash", 1)
user, _ := database.GetUserByID(context.Background(), userID)
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
// Fault-inject exactly the DM lookup; the earlier role/channel lookups
// keep working.
@@ -236,7 +236,7 @@ func TestFailedHandshake_MarksUserOfflineWhenNoReplacementRemains(t *testing.T)
if err := database.UpdateUserStatus(context.Background(), userID, "online"); err != nil {
t.Fatalf("UpdateUserStatus: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
// Old connection A holds the slot; B replaces it (registerNow kicks A);
// A's readPump defer runs while B holds the slot → teardown skipped.
+2 -2
View File
@@ -80,7 +80,7 @@ func TestSweepStaleVoiceStates_EvictionIsScopedToCheckedChannel(t *testing.T) {
t.Fatalf("UpsertChannelOverride: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
c := NewTestClient(h, uid, make(chan []byte, 2048))
h.clients[uid] = c
@@ -135,7 +135,7 @@ func TestHandleVoiceJoin_AbortedSwitchDoesNotResurrectVoiceTopicSubscription(t *
t.Fatalf("GetVoiceState: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
t.Cleanup(h.Stop) // ends the background leave retries the blocked delete spawns
lk, err := NewLiveKitClient(&config.VoiceConfig{
LiveKitAPIKey: "harvest-key",
+465 -11
View File
@@ -4,6 +4,7 @@ package ws
import (
"context"
"log/slog"
"os"
"sync"
"sync/atomic"
"time"
@@ -59,18 +60,17 @@ type Hub struct {
broadcastDrops atomic.Uint64 // counts messages dropped due to full broadcast channel
// Phase B Step 7 — event persistence. nil = ring buffer only. Atomic
// because internal/app wires these one lifecycle stage after Run has
// started, which reads them on the broadcast/replay paths.
// because main.go wires these after NewRouter has already started the
// Run loop, which reads them on the broadcast/replay paths.
eventPersister atomic.Pointer[EventPersister]
eventStore atomic.Pointer[EventStore] // read path for cold-tier replay
// Phase C Step 9 — plugin wiring.
pluginRegistry *plugin.Registry // slash-command dispatch; nil = no plugins; HubOptions field (B3-4)
pluginRegistry *plugin.Registry // slash-command dispatch; nil = no plugins; wire before Run
pluginSink atomic.Pointer[plugin.EventSink] // hub→plugin event fan-out; nil = no plugins
// running flips when Run starts. The pre-Run setters that used to check
// it died in B3-4 (their fields are HubOptions now); tests still read it
// via RunningForTest to wait for the dispatch loop.
// running flips when Run starts; plain-field setters check it so a late
// call fails loudly instead of racing the dispatch loop.
running atomic.Bool
// dispatchExited flips when Run returns for good — normal Stop or the
@@ -97,7 +97,7 @@ type Hub struct {
connRejects atomic.Uint64
// coldReplayLimit caps persisted-event replay per reconnect. 0 = the
// compiled-in default (maxColdReplay). HubOptions.ReplayColdLimit (B3-4).
// compiled-in default (maxColdReplay). Set via ConfigureReplay before Run.
coldReplayLimit int
// In-flight guards for the DB-heavy sweeps Run kicks off in their own
@@ -119,10 +119,6 @@ type Hub struct {
visibilityChangeSeq atomic.Uint64
// Settings cache — avoids per-connection DB queries for server_name/motd.
// settings is the read seam the cache below refreshes through —
// the B3-8 settings family owns the underlying reads.
settings SettingsReader
settingsMu syncutil.RWMutex
settingsName string
settingsMotd string
@@ -141,6 +137,133 @@ type Hub struct {
presenceFlushArmed bool
}
// NewHub creates a Hub ready to be started with Run.
// It also initializes the settings cache from the database.
// If svc is non-nil, V2 handlers receive service references for business logic delegation.
func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *Hub {
reg := NewHandlerRegistry()
h := &Hub{
clients: make(map[int64]*Client),
db: database,
limiter: limiter,
broadcast: make(chan broadcastMsg, 1024),
clientEvents: make(chan clientEvent, 64),
stop: make(chan struct{}),
pubsub: NewPubSub(),
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
replayBuf: NewEventRingBuffer(1000),
registry: reg,
permChecker: permissions.NewChecker(database),
settingsName: "OwnCord Server",
settingsMotd: "Welcome!",
voiceKeyHolders: make(map[int64]int64),
fatalFn: func() { os.Exit(1) },
}
// V2 handler registrations (need Hub fields for deps).
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
chatDeps := ChatDeps{
Limiter: h.limiter,
}
presenceDeps := PresenceDeps{
Limiter: h.limiter,
}
reactionDeps := ReactionDeps{}
callDeps := CallDeps{Limiter: h.limiter}
if svc != nil {
chatDeps.MessageSvc = svc.Messages
presenceDeps.ChannelSvc = svc.Channels
reactionDeps.MessageSvc = svc.Messages
callDeps.DMSvc = svc.DMs
h.messageSvc = svc.Messages
h.perms = svc.Permissions
// So @here's offline narrowing can tell a disconnected idle/dnd reader
// (users.status keeps their last *chosen* value across a disconnect)
// from one who is actually still connected — the same live-connection
// rule presentableMembers applies to the members array.
svc.Messages.SetOnlineChecker(h.IsUserConnected)
// So every DM payload DMService builds (GET/POST /dms, POST
// /dms/group, PATCH /dms/{id}, and every broadcastDMOpen refresh)
// applies the same live-connection rule instead of only the ready
// payload's presentableDMChannels doing so (OC-0304).
svc.DMs.SetOnlineChecker(h.IsUserConnected)
}
registerChatHandlers(reg, chatDeps)
registerPresenceHandlers(reg, presenceDeps)
registerReactionHandlers(reg, reactionDeps)
registerCallHandlers(reg, callDeps)
// Phase C Step 9 — plugin slash commands. Registry is read live because
// SetPluginRegistry wires it after NewHub; MessageSvc gates broadcasts.
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
// A nil registry must yield a nil interface, not a typed-nil
// *plugin.Registry — the handler's "no plugins loaded" check is an
// interface comparison.
Registry: func() CommandDispatcher {
if h.pluginRegistry == nil {
return nil
}
return h.pluginRegistry
},
MessageSvc: h.messageSvc,
Limiter: h.limiter,
})
registerVoiceControlsV2(reg, VoiceDeps{
DB: h.db,
Limiter: h.limiter,
Permissions: h.permChecker,
PermSvc: h.perms,
LiveKit: h.livekit,
TokenGen: h, // Hub delegates to h.livekit at call time (set via SetLiveKit)
KeyHolder: h,
Mod: h,
})
h.refreshSettingsLocked(context.Background())
return h
}
// getCachedSettings returns server_name and motd, refreshing the cache if stale.
func (h *Hub) getCachedSettings(ctx context.Context) (string, string) {
h.settingsMu.RLock()
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
name, motd := h.settingsName, h.settingsMotd
h.settingsMu.RUnlock()
return name, motd
}
h.settingsMu.RUnlock()
h.settingsMu.Lock()
defer h.settingsMu.Unlock()
// Double-check after acquiring write lock.
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
return h.settingsName, h.settingsMotd
}
h.refreshSettingsLocked(ctx)
return h.settingsName, h.settingsMotd
}
// refreshSettingsLocked reloads server_name and motd from the DB.
// Caller must hold settingsMu (write lock) or call during init.
func (h *Hub) refreshSettingsLocked(ctx context.Context) {
if h.db == nil {
return
}
// The refresh serves the hub-wide settings cache, not the connection that
// happened to trigger it — a dying connection's ctx must not fail the
// fetches (the TTL stamp below would then pin stale values for 30s).
ctx = context.WithoutCancel(ctx)
if name, err := h.db.GetSetting(ctx, "server_name"); err == nil {
h.settingsName = name
}
if motd, err := h.db.GetSetting(ctx, "motd"); err == nil {
h.settingsMotd = motd
}
h.settingsLastUpdate = time.Now()
}
// Run starts the hub's dispatch loop. It blocks until Stop is called.
// Must be called in its own goroutine.
//
@@ -289,6 +412,37 @@ func (h *Hub) GracefulStopContext(ctx context.Context) {
})
}
// bumpVisibilityWatermark ratchets visibilityChangeSeq up to the current seq,
// never down. All three writers (RefreshChannelVisibility,
// revokeUnreadableChannels, DMChannelOpenEvent in emit.go) must go through
// this instead of a plain Store: a plain Store(Load(&h.seq)) lets a writer
// that read an older h.seq — e.g. one that spent time in a per-topic DB loop
// — finish and overwrite a concurrently stored higher watermark with its
// stale value, silently regressing the forced-full-resync boundary mustFullResync
// depends on being monotonic. Mirrors SeedSeq's CAS-max pattern.
func (h *Hub) bumpVisibilityWatermark() {
for {
cur := h.visibilityChangeSeq.Load()
next := atomic.LoadUint64(&h.seq)
if next <= cur {
return
}
if h.visibilityChangeSeq.CompareAndSwap(cur, next) {
return
}
}
}
// MarkVisibilityChanged bumps the visibility watermark. It is the exported
// entry point REST handlers (api.markDMVisibilityChanged, reached via a
// dmVisibilityMarker type assertion) use to force the same full-resync
// guarantee for an unsequenced, targeted DM event that the WS-side emitter of
// the same event (emit.go DMChannelOpenEvent) already gets via
// bumpVisibilityWatermark directly.
func (h *Hub) MarkVisibilityChanged() {
h.bumpVisibilityWatermark()
}
// IsUserConnected returns true if a client with the given userID is already
// registered in the hub. Safe to call from any goroutine.
func (h *Hub) IsUserConnected(userID int64) bool {
@@ -306,6 +460,16 @@ func (h *Hub) GetClient(userID int64) *Client {
return h.clients[userID]
}
// Register queues a client for registration with the hub.
func (h *Hub) Register(c *Client) {
h.clientEvents <- clientEvent{c: c, add: true}
}
// Unregister queues a client for removal from the hub.
func (h *Hub) Unregister(c *Client) {
h.clientEvents <- clientEvent{c: c}
}
// clientEvent is a register (add=true) or unregister (add=false) request.
// Both kinds share one channel so per-connection ordering is preserved.
type clientEvent struct {
@@ -313,6 +477,246 @@ type clientEvent struct {
add bool
}
// registerNow adds c to the hub and subscribes it to its topics.
//
// readableChannelIDs is the set of channels the user holds READ_MESSAGES on,
// as computed by the handshake (serve.go). It gates the inherited voice-channel
// subscription only; a nil set denies it (fail closed).
//
// Replacing an existing connection strips its subscriptions (UnsubscribeAll)
// and re-subscribes the new one (Subscribe) as two separate PubSub-lock
// acquisitions — back to back, but not atomic. A caller that must not lose a
// broadcast concurrently racing the replacement (i.e. one deliverBroadcast
// could deliver in the gap between those two acquisitions) has to call this
// while holding h.seqMu, the same lock deliverBroadcast holds for its entire
// critical section (seq allocation, replay-buffer push, and publish) — that
// serializes the two entirely, rather than merely narrowing the window. See
// serve.go's handleReconnect, which re-reads the replay tail and calls
// registerNow inside one h.seqMu section for exactly this reason.
func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) {
// Voice channel the replaced connection was in, if any. Re-elected below,
// after the hub lock is released.
var replacedVoiceChID int64
h.mu.Lock()
if old, exists := h.clients[c.userID]; exists {
oldE2EEKey, oldE2EESig := old.getE2EEPubKey()
oldVoiceChID, oldVoiceJoinToken, oldVoiceJoinCompleted := old.clearVoiceState()
replacedVoiceChID = oldVoiceChID
// A moderator-imposed mute/deafen stashed by voice_mod_move
// (setPendingModFlags) lives ONLY on the old *Client between the
// target's eviction (which deletes the voice_states row that state
// normally lives in) and the target's own re-join, which consumes it
// via takePendingModFlags (voice_join.go). Any client replacement —
// reconnect or full resync alike — must carry it to the new *Client
// or it is silently destroyed and the mute is lost (OC-0302).
// Unlike the voice-state transfer below, this has none of the
// voiceJoinCompleted supersession concerns, so it is not gated on
// c.lastSeq > 0: take-and-clear leaves nothing behind for old to
// double-serve, and a stash nobody set is always (false, false).
if pendingMuted, pendingDeafened := old.takePendingModFlags(); pendingMuted || pendingDeafened {
c.setPendingModFlags(pendingMuted, pendingDeafened)
}
if c.lastSeq > 0 {
// Network reconnect — preserve voice state so the user stays
// in voice during brief WS drops.
//
// Gated on oldVoiceJoinCompleted (OC-0270): a join that
// voiceJoinPersist has merely committed to the DB and set on the
// old client, but that voiceJoinComplete has not yet finished, is
// still racing its own supersession guards in voice_join.go
// (voice_join.go:423, :470) — both compare the old client's live
// voiceChID/voiceJoinToken against the values captured when the
// join started. Clearing the old client's state above as part of
// this very transfer makes those guards read as "superseded" and
// abort the join (no token delivered, no voice_state broadcast,
// no VoiceTopic subscribe) — while the DB row and the new
// client's transferred state still agree, so sweepStaleVoiceStates
// never reaps it. Transferring only a completed join avoids
// resurrecting exactly that half-finished state; an incomplete
// one instead leaves the new client with voiceChID 0, so the
// still-committed row now disagrees with hub state and the next
// sweep tick reaps it, letting the user rejoin.
if c.getVoiceChID() == 0 && oldVoiceJoinCompleted {
c.setVoiceState(oldVoiceChID, oldVoiceJoinToken)
// c.setVoiceState above resets the fresh-join-in-progress flag
// it defaults to; restore it since we just verified the old
// client's join over this same (chID, token) had completed.
c.markVoiceJoinCompleteIfMatch(oldVoiceChID, oldVoiceJoinToken)
// The announced ECDH key must survive with the voice state:
// the client keeps its keypair across a WS blip and only
// re-announces on a LiveKit-room reconnect, so without the
// transfer voice_join replays nothing for this user and new
// joiners' key exchanges time out.
c.setE2EEPubKey(oldE2EEKey, oldE2EESig)
}
// The focused channel must transfer too: the client never
// re-sends channel_focus on a resume (mountChannel early-returns
// on the same channel), so without it the ChannelTopic
// re-subscribe below is a no-op and the message stream dies
// silently. READ-gated like every ChannelTopic subscription;
// a nil set denies (fail closed).
if oldChID := old.getChannelID(); oldChID != 0 &&
c.getChannelID() == 0 && readableChannelIDs[oldChID] {
c.mu.Lock()
c.channelID = oldChID
c.mu.Unlock()
}
}
// Fresh connections (lastSeq == 0): do NOT transfer voice state.
// Stale voice cleanup (DB + broadcast + LiveKit) is owned entirely
// by the handshake path in serve.go, which runs before registerNow.
// registerNow only handles in-memory client replacement.
// Kick the stale connection atomically before registering
// the new one — prevents TOCTOU races on duplicate login.
// closeSend MUST precede UnsubscribeAll: Subscribe refuses clients
// whose send is closed, so this ordering leaves the old connection's
// in-flight handlers no window to re-take a stripped topic.
slog.Warn("hub: kicking stale connection for re-registering user",
"user_id", c.userID, "last_seq", c.lastSeq)
old.closeSend()
// Remove the old client from all pub/sub topics before replacing.
h.pubsub.UnsubscribeAll(old)
}
h.clients[c.userID] = c
// Subscribe the new client to its default pub/sub topics immediately
// after UnsubscribeAll(old) above, with nothing in between.
//
// This does NOT make strip+resubscribe atomic, and must not be read as
// doing so: the two are separate ps.mu acquisitions, and PublishGlobal
// takes ps.mu alone (never h.mu), so a deliverBroadcast landing between
// them still finds no subscriber for this user. That frame is
// unrecoverable — its seq was already allocated and pushed to the replay
// buffer, the resuming client's replay snapshot was taken even earlier,
// and the client tracks only max(seq), so the next frame silently
// advances past the hole. Only a caller holding h.seqMu closes that
// window; see this function's doc comment and serve.go's handleReconnect.
//
// What the ordering does buy is the smallest possible gap for the callers
// that cannot hold seqMu — the fresh-connect path, whose buildReady
// rebuilds state from the DB afterwards, and the clientEvents path, which
// runs on the hub goroutine and so cannot race deliverBroadcast at all.
// The registration log line (a syscall-backed slog call) and
// updateKeyHolder (keyHolderMu plus a full h.clients scan under
// h.mu.RLock) both used to sit in that gap; both now run after the
// subscribes. Keeping the subscribes under h.mu is incidental but free:
// pubsub uses its own independent lock and never calls back into the hub,
// so h.mu → ps.mu adds no lock-ordering risk.
h.pubsub.Subscribe(c, TopicGlobal)
h.pubsub.Subscribe(c, UserTopic(c.userID))
// If the client already has a focused channel (e.g. test clients created with
// NewTestClientWithChannel, or reconnecting clients), subscribe immediately so
// deliverBroadcast can reach them without waiting for a channel_focus message.
if chID := c.getChannelID(); chID != 0 {
h.pubsub.Subscribe(c, ChannelTopic(chID))
}
// If the client is already in a voice channel (e.g. reconnect), restore its
// subscriptions without a new voice_join (a same-channel rejoin is rejected
// with ALREADY_JOINED) or channel_focus.
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
// VoiceTopic is the only transport for voice_e2ee_announce relays and
// carries nothing else, for a channel the user already joined via the
// CONNECT_VOICE-gated voice_join — so no READ gate.
h.pubsub.Subscribe(c, VoiceTopic(voiceChID))
// Voice membership is gated on CONNECT_VOICE alone, so it must not by
// itself grant a channel's message stream: subscribe only when the
// handshake confirmed READ_MESSAGES on that channel.
if readableChannelIDs[voiceChID] {
h.pubsub.Subscribe(c, ChannelTopic(voiceChID))
}
}
total := len(h.clients)
h.mu.Unlock()
slog.Info("hub: client registered", "user_id", c.userID, "total_clients", total)
// A fresh connect (lastSeq == 0) drops the replaced connection's voice state
// without transferring it, so that channel just lost a participant and the
// E2EE key holder may need to move. handleVoiceLeave never runs on this path
// — readPump skips it when replaced, and it early-returns on already-cleared
// state — so re-elect here. Must be outside h.mu: updateKeyHolder takes
// keyHolderMu and then h.mu.RLock. The recompute reads live client voice
// state, so it is idempotent and also correct when the state was transferred.
// It runs after the subscribe block above; updateKeyHolder only reads
// h.clients' voice state and writes voiceKeyHolders, so it has no
// ordering dependency on pub/sub subscriptions.
if replacedVoiceChID != 0 {
h.updateKeyHolder(replacedVoiceChID)
}
// Re-sync this connection's local E2EE peer-key map now that it is
// reachable (OC-0276). voice_e2ee_announce is delivered as an
// unsequenced pub/sub frame (sendToVoiceChannelExcept, voice_e2ee.go),
// bypassing deliverBroadcast/h.replayBuf entirely — so on a network
// reconnect (the transfer above), neither reconnect replay tier can ever
// redeliver a peer's key, or a mid-call key rotation, that was announced
// while this socket was down. voiceJoinComplete's relay
// (voice_join.go) only runs on a brand-new voice_join, never here, so
// without this call a resumed connection's peer-key map would silently
// and permanently desync from its (correctly replayed) voice roster.
// c.getVoiceChID() reflects the transfer above, so this covers a
// resumed connection as well as a client pre-set into a voice channel
// (e.g. NewTestClientWithChannel); it is a no-op whenever c is not
// currently in a voice channel, which is the common case (fresh login).
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
h.sendVoicePeerKeys(c, voiceChID)
// Re-relay THIS client's own stored key back onto VoiceTopic (OC-0316).
// voice_e2ee_offer (the room-key-bearing message) is a targeted,
// unsequenced send that is silently dropped if this socket was down
// when it went out (sendToUserIfInVoiceChannel, voice_e2ee.go) — and
// unlike voice_e2ee_announce it has no reconnect-replay recovery
// path either. A key rotation sent during the outage otherwise
// strands this client on a dead key with no signal and no retry
// until the key holder's next periodic rotation. The client's
// duplicate-announce handling already re-wraps and re-offers the
// CURRENT room key whenever it sees a peer announce a key it
// already knows, so re-announcing our own (unchanged) key is enough
// to make the key holder re-offer — no client change needed.
if key, sig := c.getE2EEPubKey(); key != "" {
h.sendToVoiceChannelExcept(voiceChID, c.userID, buildVoiceE2EEAnnounce(c.userID, key, sig))
}
}
}
func (h *Hub) unregisterNow(c *Client) bool {
h.mu.Lock()
current, exists := h.clients[c.userID]
if exists && current == c {
delete(h.clients, c.userID)
slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients))
h.mu.Unlock()
h.pubsub.UnsubscribeAll(c)
return false // not replaced
}
h.mu.Unlock()
// exists means a *different* client holds the slot — a genuine replacement,
// whose teardown must not mark the live connection's user offline. An absent
// entry means this client was already kicked (every kick path deletes it via
// kickClient), which is a real disconnect and still needs the offline
// presence broadcast and voice cleanup in readPump's defer.
return exists
}
// shouldMarkOffline reports whether a disconnect teardown should run
// MarkUserDisconnected and broadcast an offline presence for c's user.
//
// `replaced` (unregisterNow's return, sampled once at the start of teardown)
// is necessary but not sufficient: both readPump's defer and
// unregisterFailedHandshake sample it BEFORE handleVoiceLeave, which can
// block for seconds (DB delete, audience scan, a LiveKit call bounded by
// lkTimeout=5s). A reconnect landing during that window registers a new
// client for the same user and is invisible to the stale boolean, so the
// dead connection's teardown would otherwise mark the live session offline
// (OC-0019). Re-checking h.clients at decision time closes that gap: any
// entry present once c has been removed is necessarily a newer connection —
// unregisterNow only ever deletes c's own slot, never someone else's.
func (h *Hub) shouldMarkOffline(c *Client, replaced bool) bool {
return !replaced && h.GetClient(c.userID) == nil
}
// ClientCount returns the number of currently registered clients (test helper).
func (h *Hub) ClientCount() int {
h.mu.RLock()
@@ -348,6 +752,30 @@ func (h *Hub) ConnRejectCount() uint64 {
return h.connRejects.Load()
}
// ConfigureReplay resizes the reconnect replay budget: the in-memory ring and
// the persisted-event cap per reconnect (event_persistence.replay_ring_size /
// replay_cold_limit). Zero or negative values keep the compiled-in defaults.
// Must be called before Run — the dispatch loop reads replayBuf unlocked.
func (h *Hub) ConfigureReplay(ringSize, coldLimit int) {
if h.rejectIfRunning("ConfigureReplay") {
return
}
if ringSize > 0 {
h.replayBuf = NewEventRingBuffer(ringSize)
}
if coldLimit > 0 {
h.coldReplayLimit = coldLimit
}
}
// maxColdReplayLimit returns the effective persisted-replay cap.
func (h *Hub) maxColdReplayLimit() int {
if h.coldReplayLimit > 0 {
return h.coldReplayLimit
}
return maxColdReplay
}
// EventPersisterStats returns the attached persister's lifetime counters.
// ok is false when event persistence is disabled (no persister attached).
func (h *Hub) EventPersisterStats() (persisted, dropped, flushes, errs uint64, ok bool) {
@@ -359,6 +787,32 @@ func (h *Hub) EventPersisterStats() (persisted, dropped, flushes, errs uint64, o
return persisted, dropped, flushes, errs, true
}
// VoiceSessionCount returns the number of clients currently in a voice channel.
func (h *Hub) VoiceSessionCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
count := 0
for _, c := range h.clients {
if c.getVoiceChID() != 0 {
count++
}
}
return count
}
// rejectIfRunning reports whether Run has already started, logging an error
// when it has. Plain-field setters must be wired before Run: the dispatch
// loop and connection goroutines read those fields without synchronization,
// so a late set would be a data race. Late calls are dropped.
func (h *Hub) rejectIfRunning(setter string) bool {
if h.running.Load() {
slog.Error("ws: setter called after Hub.Run started; ignoring (must be wired before Run)",
"setter", setter)
return true
}
return false
}
// topicRateLimitPerSecond is the default maximum messages per second for any
// single channel topic. Prevents a busy channel from saturating the broadcast
// loop and starving other channels.
+608
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/telemetry"
)
@@ -79,6 +80,69 @@ func (h *Hub) BroadcastToAllExcept(excludeUserID int64, msg []byte) {
}
}
// broadcastVoiceEvent enqueues a voice_state / voice_leave message for the
// connected clients whose current role may READ channelID.
//
// These events used to go out via BroadcastToAll, which handed every
// authenticated client the membership and camera/mute state of voice channels
// that channel_overrides hides from their role — while the equivalent read path
// (buildReady) deliberately filters voice states to readable channels. Tagging
// the event with its real channel id also makes reconnect replay filter it,
// where a channelID of 0 was replayed unconditionally.
//
// The audience is resolved here, on the caller's goroutine, so the hub's
// dispatch loop never blocks on permission lookups.
func (h *Hub) broadcastVoiceEvent(ctx context.Context, channelID int64, msg []byte) {
// A room's own participants must always receive its voice_state /
// voice_leave: voice membership is gated on CONNECT_VOICE alone, so the
// READ filter can exclude a live participant — whose client then keeps a
// stale E2EE key holder, stalling rotation and locking new joiners out
// until e2ee_timeout. Union the READ audience with the room's current
// participants; what outsiders may observe is unchanged.
audience := h.channelReadAudience(ctx, channelID)
seen := make(map[int64]struct{}, len(audience))
for _, uid := range audience {
seen[uid] = struct{}{}
}
h.mu.RLock()
for uid, c := range h.clients {
if _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID {
audience = append(audience, uid)
}
}
h.mu.RUnlock()
h.broadcastChannelScopedTo(channelID, msg, audience, "voice event")
}
// broadcastVoiceEventWithLeaver is broadcastVoiceEvent extended to guarantee
// leaverID is in the audience even though the caller has already cleared
// their client-side voice state — which means broadcastVoiceEvent's own
// still-in-the-room participant union can no longer see them. Every path
// that tears down a voice participant whose client state is cleared before
// the voice_leave goes out needs this: voice membership is gated on
// CONNECT_VOICE alone, so a leaver without READ_MESSAGES on the channel
// would otherwise never learn the server already ended their call. Mirrors
// CleanupVoiceForChannel's per-batch leaver union, for the single-leaver case.
func (h *Hub) broadcastVoiceEventWithLeaver(ctx context.Context, channelID int64, msg []byte, leaverID int64) {
audience := h.channelReadAudience(ctx, channelID)
seen := make(map[int64]struct{}, len(audience)+1)
for _, uid := range audience {
seen[uid] = struct{}{}
}
h.mu.RLock()
for uid, c := range h.clients {
if _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID {
seen[uid] = struct{}{}
audience = append(audience, uid)
}
}
h.mu.RUnlock()
if _, ok := seen[leaverID]; !ok {
audience = append(audience, leaverID)
}
h.broadcastChannelScopedTo(channelID, msg, audience, "voice event")
}
// broadcastChannelScoped enqueues msg for exactly the connected clients whose
// current role may READ channelID, tagged with that channel id so reconnect
// replay filters it too (EventsSinceFiltered replays a channelID of 0
@@ -109,6 +173,138 @@ func (h *Hub) broadcastChannelScopedTo(channelID int64, msg []byte, recipients [
}
}
// channelReadAudience returns the connected user IDs whose current role may READ
// channelID. Always non-nil, so an empty result means "deliver to nobody"
// rather than "no filter". Each user's verdict comes from the cached
// PermissionService when the hub has one (one in-memory lookup per connected
// user; a miss repopulates from the user's CURRENT role, so a mid-session
// reassignment is still honored). Caching is safe here because revocation is
// delivered synchronously at every mutation site: a role change calls
// InvalidateUser (admin/handlers_users.go) and a channel-override change calls
// InvalidateAll (admin/handlers_channel_perms.go) before the hub fan-out runs,
// with the 30s cache TTL as a backstop; the F6 gen-counter guard in the service
// prevents a populate racing an invalidation from caching stale data. Fails
// closed: a client whose role cannot be resolved is left out. Bare test hubs
// without a service fall back to live per-call lookups, memoised for the
// duration of the call. Mirrors RefreshChannelVisibility, which resolves
// visibility the same way.
func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 {
return h.channelReadAudienceImpl(ctx, channelID, false)
}
// channelReadAudienceIgnoringArchived is channelReadAudience without the
// Archived short-circuit (OC-0022). CleanupVoiceForChannel's only two
// callers (admin/handlers_channels.go's archive and delete paths) always
// commit archived=1 to the channel before evicting its voice participants —
// deliberately, per admin/api_test.go's
// TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup, so a concurrent
// voice_join sees the archived gate. That means channelReadAudience's own
// Archived check, evaluated from CleanupVoiceForChannel, always sees the
// channel already archived and always returns nobody: the voice_leave that
// should tell every bystander who could see the room a moment ago that the
// call ended never reaches them, only the evicted participants themselves
// (added back by CleanupVoiceForChannel's own loop). This resolves that same
// pre-archival READ audience for exactly that one broadcast, leaving every
// other channelReadAudience call site (and its archived-channel behavior)
// untouched.
func (h *Hub) channelReadAudienceIgnoringArchived(ctx context.Context, channelID int64) []int64 {
return h.channelReadAudienceImpl(ctx, channelID, true)
}
func (h *Hub) channelReadAudienceImpl(ctx context.Context, channelID int64, ignoreArchived bool) []int64 {
h.mu.RLock()
userIDs := make([]int64, 0, len(h.clients))
for uid := range h.clients {
userIDs = append(userIDs, uid)
}
h.mu.RUnlock()
// A DM channel carries no channel_overrides rows, so every connected
// user whose base role holds READ_MESSAGES would otherwise pass the role
// scan below — leaking a private DM call's voice_state/voice_leave
// events to the whole server. Resolve the DM's real audience (its
// participants, intersected with who is actually connected) instead,
// mirroring the IsDMParticipant membership rule hasChannelAccess uses.
var ref permissions.ChannelRef
if h.db != nil {
ch, err := h.db.GetChannel(ctx, channelID)
if err != nil {
// Fail closed: an unresolvable channel must not fall through to
// the role scan, which would treat it as a readable non-DM channel.
slog.Error("ws: channelReadAudience GetChannel failed, denying",
"channel_id", channelID, "err", err)
return []int64{}
}
// Fail closed on a missing row too (OC-0090): GetChannel returns
// (nil, nil) for a deleted channel, and falling through would hand a
// channel with no override rows left to the role scan below — which
// resolves to every connected user with base READ_MESSAGES, leaking
// e.g. a closed group-DM's voice_leave server-wide. Callers that
// tear down voice union the room's participants and the leaver back
// in afterwards, so eviction/E2EE-teardown signals still arrive.
if ch == nil {
return []int64{}
}
if ch.Type == "dm" {
return h.channelReadAudienceDM(ctx, channelID, userIDs)
}
ref = channelRef(ch)
// CanViewChannel hides an archived channel from everyone, mirroring
// RefreshChannelVisibility and VisibleChannelIDs: without that, an
// admin edit to an archived channel (or a voice teardown inside one)
// would fan out to every connected user whose base role holds
// READ_MESSAGES, none of whom have the channel in their sidebar.
// ignoreArchived resolves the pre-archival audience instead — see
// channelReadAudienceIgnoringArchived.
if ignoreArchived {
ref.Archived = false
}
}
// Resolved per USER, not memoised per role: channel_user_overrides is the
// last layer of the resolution order, so two members of the same role can
// legitimately disagree about one channel and a per-role memo would hand
// one of them the other's verdict. The verdict is CanViewChannel over
// subjectFor (cached service or live checker); an unresolvable user is
// left out.
audience := make([]int64, 0, len(userIDs))
for _, uid := range userIDs {
sub, err := h.subjectFor(ctx, uid, channelID)
if err != nil {
continue
}
sub.Channel = ref
if permissions.CanViewChannel(sub) == nil {
audience = append(audience, uid)
}
}
return audience
}
// channelReadAudienceDM resolves the audience of a DM channel: the DM's
// participants, intersected with the connected userIDs. Split verbatim out of
// channelReadAudienceImpl; the reason a DM must not fall through to the role
// scan is on the call site.
func (h *Hub) channelReadAudienceDM(ctx context.Context, channelID int64, userIDs []int64) []int64 {
participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)
if err != nil {
slog.Error("ws: channelReadAudience GetDMParticipantIDs failed, denying",
"channel_id", channelID, "err", err)
return []int64{}
}
connected := make(map[int64]struct{}, len(userIDs))
for _, uid := range userIDs {
connected[uid] = struct{}{}
}
audience := make([]int64, 0, len(participantIDs))
for _, uid := range participantIDs {
if _, ok := connected[uid]; ok {
audience = append(audience, uid)
}
}
return audience
}
// BroadcastServerRestart sends a server_restart message to all connected clients.
// reason describes why the server is restarting (e.g., "update").
// delaySeconds tells clients how long until the server actually shuts down.
@@ -158,6 +354,167 @@ func (h *Hub) BroadcastChannelDelete(channelID int64) {
// voiceJoinPostTokenRaceHook / cleanupVoiceRaceClearHook pattern.
var refreshChannelVisibilityRaceHook func(userID int64)
// RefreshChannelVisibility re-evaluates which connected clients may see ch
// after a channel_overrides change and sends targeted channel_create /
// channel_delete messages so sidebars converge without a reconnect. Clients
// that lose visibility are also unsubscribed from the channel topic and have
// their focused channel cleared so live messages stop flowing.
//
// The sends deliberately bypass the sequenced broadcast/replay path: a
// replayed channel_delete would be filtered by the allowed-channel set
// computed at replay time, which after an override change is exactly the
// inverse of the intended audience. Clients tolerate seq-less messages.
func (h *Hub) RefreshChannelVisibility(ch *db.Channel) {
if ch == nil {
return
}
// Bump the watermark immediately, before the h.clients snapshot below and
// the (potentially slow — up to two DB round trips per connected client)
// fan-out loop that follows it. A reconnect handshake re-checks this
// watermark right before it registers (OC-0206); bumping only at the end,
// after the loop, left a window where that re-check could still observe
// the pre-change value even though this function's snapshot — taken next
// — will never include a client that registers mid-loop. Ratcheted
// upward only (see bumpVisibilityWatermark), so this is a no-op whenever
// a concurrent writer already pushed the watermark higher; the trailing
// bump below still runs and covers any change to h.seq made during the
// loop itself.
h.bumpVisibilityWatermark()
h.mu.RLock()
clients := make([]*Client, 0, len(h.clients))
for _, c := range h.clients {
clients = append(clients, c)
}
h.mu.RUnlock()
// Called via the admin HubBroadcaster interface, which carries no context;
// the targeted re-sync must complete regardless of the triggering request.
ctx := context.Background()
// Visibility is CanViewChannel — the single predicate shared with
// buildReady / REST ListVisibleChannels — resolved per user from their
// CURRENT role (c.user is a connect-time snapshot). With a
// PermissionService the subject comes from the per-user cache — safe
// because the admin handlers invalidate (InvalidateAll on override
// change, InvalidateUser on role change) before calling into the hub, so
// the lookups below repopulate from post-change data; the 30s TTL is only
// a backstop and the F6 gen-counter guard keeps a racing populate from
// caching stale rows. Without a service (bare test hubs) each client is
// resolved live. Fails closed: an unresolvable role loses visibility
// rather than keeping a stale grant.
//
// Deliberately NOT memoised per role: channel_user_overrides is the last
// layer of the resolution order, so two members of the same role can
// legitimately disagree about one channel — exactly the case a per-user
// override edit creates, and exactly the fan-out this function targets.
for _, c := range clients {
if c.user == nil {
continue
}
sub, err := h.subjectFor(ctx, c.user.ID, ch.ID)
if err != nil {
slog.Warn("hub: RefreshChannelVisibility could not resolve permissions, revoking",
"user_id", c.user.ID, "channel_id", ch.ID, "err", err)
}
sub.Channel = channelRef(ch)
visible := err == nil && permissions.CanViewChannel(sub) == nil
if refreshChannelVisibilityRaceHook != nil {
refreshChannelVisibilityRaceHook(c.user.ID)
}
// Re-resolve the live client immediately before acting: the permission
// lookups above (a PermissionService call, or two DB round trips in the
// bare-hub branch) give a reconnect room to replace this user's *Client
// in h.clients with a new connection under the same user ID. Acting on
// the stale snapshot pointer c would target a dead socket, and
// Unsubscribe would be a no-op — unsubscribeLocked's identity guard
// leaves a topic alone when the current holder differs from the client
// passed in — stranding the replacement with a subscription (or a
// missing one) exactly inverted from what this fan-out just decided.
// A nil result means the user disconnected entirely since the
// snapshot; nothing to act on.
live := h.GetClient(c.user.ID)
if live == nil {
continue
}
if visible {
// Idempotent add on the client; also refreshes channel metadata.
// Addressed per client so it can carry this recipient's own
// can_send verdict — the whole point of this fan-out is that a
// permission change just made those verdicts diverge.
live.sendMsg(buildChannelCreateFor(ch, h.refreshChannelVisibilityCanSend(ctx, ch, c.user.ID)))
continue
}
live.sendMsg(buildChannelDelete(ch.ID))
h.pubsub.Unsubscribe(live, ChannelTopic(ch.ID))
live.mu.Lock()
if live.channelID == ch.ID {
live.channelID = 0
}
live.mu.Unlock()
}
// Clients not connected right now missed the targeted sends above. Move
// the watermark so any resume from a seq at or before this point is
// forced onto the full-ready path instead of replay. Ratcheted upward
// only — see bumpVisibilityWatermark — so a concurrent writer that read
// an older seq cannot regress a watermark another writer already pushed
// higher.
h.bumpVisibilityWatermark()
}
// refreshChannelVisibilityCanSend is the can_send verdict the ready payload
// ships per channel (channelCanSend), recomputed for one live user from their
// CURRENT role: permissions.CanSendMessage over the subject subjectFor
// resolves in either the service or the bare-hub branch, failing closed on a
// lookup error (S-12).
//
// Without this, can_send is only ever computed at connect time, so a role
// edit or override edit leaves every connected client's composer stuck on
// its stale connect-time verdict until the socket is rebuilt.
func (h *Hub) refreshChannelVisibilityCanSend(ctx context.Context, ch *db.Channel, userID int64) bool {
sub, err := h.subjectFor(ctx, userID, ch.ID)
if err != nil {
return false
}
sub.Channel = channelRef(ch)
return permissions.CanSendMessage(sub) == nil
}
// RefreshAllChannelVisibility re-runs RefreshChannelVisibility for every
// non-DM channel. A role's permission mask is the base every channel's
// effective permission is computed from, so editing or deleting a role can
// change visibility of *any* channel at once — where a channel_overrides edit
// touches exactly one. DM channels are skipped: their access is participant-
// based and no role change can alter it.
//
// Called via the admin HubBroadcaster interface (no context), so the channel
// list is read against Background — the re-sync must complete regardless of the
// triggering request. The caller invalidates the permission cache first, as the
// channel-override handlers do, so the per-client lookups below repopulate from
// post-change data.
func (h *Hub) RefreshAllChannelVisibility() {
if h.db == nil {
return
}
ctx := context.Background()
channels, err := h.db.ListChannels(ctx)
if err != nil {
slog.Warn("hub: RefreshAllChannelVisibility could not list channels", "err", err)
return
}
for i := range channels {
if channels[i].Type == "dm" {
continue
}
h.RefreshChannelVisibility(&channels[i])
}
}
// BroadcastRolesUpdate sends the full role list to every connected client so
// name colors and permission-gated affordances converge without a reconnect.
//
@@ -240,6 +597,151 @@ func (h *Hub) BroadcastUserUpdate(u UserUpdate) {
h.BroadcastToAll(buildUserUpdate(u))
}
// presenceCoalesceWindow is how long QueuePresence buffers connect/disconnect
// presence before flushing. Long enough to collapse a socket flap
// (disconnect+reconnect through a proxy blip) into one frame, short enough
// that a genuine arrival still looks immediate to humans.
const presenceCoalesceWindow = 300 * time.Millisecond
// pendingPresence is the coalescer's latest-wins entry for one user.
type pendingPresence struct {
status string
customStatus *string
}
// QueuePresence coalesces connect/disconnect presence broadcasts: the latest
// state per user is buffered for presenceCoalesceWindow and then flushed via
// BroadcastPresence. Each un-coalesced presence change is a sequenced global
// broadcast — an O(connected clients) fan-out under seqMu — so a reconnect
// storm (proxy blip, deploy, network hiccup) used to fire O(users) of them
// from the connect critical path all at once. Latest-wins is exactly
// presence's semantics: a flap inside the window collapses to its final
// state, and the flushed frames are ordinary sequenced presence messages, so
// the wire format and replay behaviour are unchanged. User-chosen status
// changes (presence_update handler) do not pass through here.
func (h *Hub) QueuePresence(userID int64, status string, customStatus *string) {
h.presenceMu.Lock()
if h.presenceQueue == nil {
h.presenceQueue = make(map[int64]pendingPresence)
}
h.presenceQueue[userID] = pendingPresence{status: status, customStatus: customStatus}
armed := h.presenceFlushArmed
h.presenceFlushArmed = true
h.presenceMu.Unlock()
if !armed {
time.AfterFunc(presenceCoalesceWindow, h.flushPresenceQueue)
}
}
// dropQueuedPresenceAndBroadcast atomically removes any coalesced presence
// still queued for userID and runs broadcast, both under presenceMu. Called
// when a fresher presence for that user is delivered directly (the
// presence_update handler path, via EmitEvents), so the delete and the send
// of the fresher frame can never straddle flushPresenceQueue's own
// snapshot-and-broadcast critical section (OC-0005).
//
// Holding presenceMu across the delete AND the broadcast — rather than just
// the delete — is what actually closes the race: whichever of this call and
// flushPresenceQueue acquires presenceMu second also enqueues its broadcast
// second.
// - If this call goes first, it deletes the entry before flush can ever
// snapshot it, so flush never broadcasts the stale state at all.
// - If flush goes first, this call's delete is a no-op against the
// already-cleared queue, but its broadcast still cannot run until flush's
// own broadcast has already been enqueued — so the fresher frame is
// stamped with the higher seq by deliverBroadcast's single FIFO consumer
// and every client's final view converges on it, not the stale one.
//
// broadcast runs with presenceMu held: every current caller (BroadcastToAll,
// BroadcastToAllExcept) only enqueues onto h.broadcast's non-blocking
// channel send, so this cannot block and introduces no new lock-order edge.
// Both callers sharing that same channel also means the "enqueues second"
// ordering guarantee above translates directly into delivery order: both
// broadcasts are drained by the same single-consumer hub dispatch loop
// (deliverBroadcast), in the order they were enqueued.
func (h *Hub) dropQueuedPresenceAndBroadcast(userID int64, broadcast func()) {
h.presenceMu.Lock()
defer h.presenceMu.Unlock()
delete(h.presenceQueue, userID)
broadcast()
}
// presenceFlushRaceHook, when non-nil, runs once per flushPresenceQueue call
// immediately after the coalesced queue has been snapshotted and cleared,
// while presenceMu is still held. Test-only (always nil in production): the
// snapshot-to-broadcast window is too narrow to land a real concurrent
// dropQueuedPresenceAndBroadcast reliably, so tests use this hook to
// reproduce that interleaving deterministically. Mirrors the established
// refreshChannelVisibilityRaceHook / voiceJoinPostTokenRaceHook pattern.
//
// It is handed the Hub being flushed, and a test that installs it MUST
// ignore calls for any other Hub. The hook is package-global but flushes are
// per-Hub, and QueuePresence's AfterFunc outlives the test that armed it:
// dropQueuedPresenceAndBroadcast clears the queue without disarming the
// timer, so a sibling test's 300ms flush fires long after that test returned
// — into whatever hook is installed by then. Passing the Hub is what lets the
// installer tell its own flush from that stray one; without it, a hook body
// that is only safe to run once (closing a channel, say) panics.
var presenceFlushRaceHook func(*Hub)
// flushPresenceQueue drains the coalescer and broadcasts each user's latest
// presence, all under presenceMu (OC-0005). Runs on the AfterFunc timer
// goroutine.
//
// presenceMu is held across the broadcast loop, not just the snapshot: it
// used to be released beforehand, which let a concurrent
// dropQueuedPresenceAndBroadcast (nee dropQueuedPresence) call race in after
// the snapshot had already escaped the lock. The drop was then a guaranteed
// no-op against the live (already-nilled) map, AND nothing constrained
// whether that call's own fresher broadcast landed on h.broadcast before or
// after this loop's stale one — so the stale connect-time presence could win
// the seq race and permanently overwrite a user-chosen status. Holding the
// lock here forces the two critical sections to serialize, which is what
// dropQueuedPresenceAndBroadcast's ordering guarantee depends on.
func (h *Hub) flushPresenceQueue() {
h.presenceMu.Lock()
defer h.presenceMu.Unlock()
queued := h.presenceQueue
h.presenceQueue = nil
h.presenceFlushArmed = false
if presenceFlushRaceHook != nil {
presenceFlushRaceHook(h)
}
for uid, p := range queued {
h.BroadcastPresence(uid, p.status, p.customStatus)
}
}
// BroadcastPresence fans a presence change out with the invisible mapping
// applied: everyone else sees db.BroadcastStatus(status), the user themselves
// sees the truth. It is the non-handler counterpart of presenceEvents, used by
// the connect and disconnect paths (via the QueuePresence coalescer, which
// delivers through here).
func (h *Hub) BroadcastPresence(userID int64, status string, customStatus *string) {
public := db.BroadcastStatus(status)
if public == status {
h.BroadcastToAll(buildPresenceMsg(userID, status, customStatus))
return
}
// The public frame's status already collapsed to db.BroadcastStatus, but
// customStatus does not: passing it through verbatim would tell every
// other client an "offline" member's real free-text status, which is a
// tell that they are actually online. Blank it explicitly (not omitted —
// presencePayload.CustomStatus has no omitempty) so the client clears any
// cached text, matching what db.MemberSummary.ForViewer already does for
// the ready payload's member list.
//
// Normal priority, excluding the owner (BroadcastToAllExcept), not
// broadcastExcludeLow: the low-priority queue is unsequenced and dropped
// (not disconnected) on overflow, so it could silently lose this frame
// with no replay recovery, and — since writePump always drains normal
// strictly before low — deliver it out of order against the very
// connect/disconnect presence frames this same coalescer flush also
// produces for other users via BroadcastToAll (OC-0003).
h.BroadcastToAllExcept(userID, buildPresenceMsg(userID, public, nil))
h.SendToUser(userID, buildPresenceMsg(userID, status, customStatus))
}
// BroadcastMemberUpdate sends a member_update message to all connected clients
// and re-evaluates the reassigned user's live channel subscriptions.
func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {
@@ -268,6 +770,112 @@ func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {
// pattern as refreshChannelVisibilityRaceHook.
var revokeUnreadableChannelsPreActRaceHook func(userID int64)
func (h *Hub) revokeUnreadableChannels(userID int64) {
// Ratcheted upward only (see bumpVisibilityWatermark), and evaluated at
// defer-RUN time — not the plain Store(Load(&h.seq)) this used to be,
// whose argument would have been evaluated at this defer STATEMENT,
// capturing entry-time seq and stomping any higher watermark stored by a
// concurrent writer during the per-topic DB loop below. Deferred because
// it must cover the early returns too: a user who is offline, or whose
// socket is closed below, converges via the full-ready path.
defer h.bumpVisibilityWatermark()
// Also bump immediately, before the h.clients lookup below and the
// per-topic DB loop (a GetChannel round trip per revoked topic) that
// follows it — see RefreshChannelVisibility's matching early bump and
// OC-0206. Ratcheted upward only, so this is a no-op whenever a
// concurrent writer already pushed the watermark higher; the deferred
// bump above still covers every return path, including the early ones.
h.bumpVisibilityWatermark()
if h.db == nil {
return
}
h.mu.RLock()
c, ok := h.clients[userID]
h.mu.RUnlock()
if !ok || c.user == nil {
return
}
// Called via the admin HubBroadcaster interface, which carries no context;
// the re-evaluation must complete regardless of the triggering request.
ctx := context.Background()
// c.user is a connect-time snapshot and the role just changed, so resolve
// the current user — and through it the current role — from the DB.
var allowed map[int64]bool
user, err := h.db.GetUserByID(ctx, userID)
if err == nil && user != nil {
// Same predicate as the ready payload and reconnect replay filtering.
allowed, err = h.computeAllowedChannels(ctx, h.db, user)
}
if err != nil || user == nil {
// Visibility unresolved. Keeping the old subscriptions would leak, and
// revoking them all would hollow out a sidebar the user may still be
// entitled to, so close the socket instead: the client reconnects and
// rebuilds from a ready payload computed with the new role. kickClient
// rather than DisconnectUser — the latter sends a BANNED error, which
// makes the client clear its credentials instead of reconnecting.
slog.Warn("hub: role change visibility unresolved, closing socket",
"user_id", userID, "err", err)
// Re-resolve before kicking: the lookups above are DB round trips a
// reconnect can overlap, and kicking the stale snapshot would close a
// dead socket while the replacement keeps its subscriptions.
if live := h.GetClient(userID); live != nil {
h.kickClient(live)
}
return
}
for _, topic := range h.pubsub.TopicsForClient(userID) {
chID := channelTopicID(topic)
if chID == 0 || allowed[chID] {
continue
}
// DM access is gated on dm_participants, which no role change can
// alter, while allowed sources DMs from dm_open_state — a DM the user
// has closed (or every DM, if the DM lookup inside
// computeAllowedChannels failed) is missing from allowed even though
// its subscription is still legitimate. Never revoke a DM topic here;
// on a lookup error close the socket rather than guess.
ch, chErr := h.db.GetChannel(ctx, chID)
if chErr != nil {
slog.Warn("hub: role change channel lookup failed, closing socket",
"user_id", userID, "channel_id", chID, "err", chErr)
if live := h.GetClient(userID); live != nil {
h.kickClient(live)
}
return
}
if ch != nil && ch.Type == "dm" {
continue
}
if revokeUnreadableChannelsPreActRaceHook != nil {
revokeUnreadableChannelsPreActRaceHook(userID)
}
// Re-resolve the live client immediately before acting: the DB round
// trips above (and computeAllowedChannels before the loop) give a
// reconnect room to replace this user's *Client in h.clients. Acting
// on the snapshot c would target the dead socket, and Unsubscribe
// would no-op on unsubscribeLocked's identity guard — stranding the
// replacement with the revoked topic (audit-2026-08-19 F-2; mirrors
// RefreshChannelVisibility's live re-resolve). A nil result means the
// user disconnected entirely; nothing left to revoke.
live := h.GetClient(userID)
if live == nil {
return
}
live.sendMsg(buildChannelDelete(chID))
h.pubsub.Unsubscribe(live, topic)
live.mu.Lock()
if live.channelID == chID {
live.channelID = 0
}
live.mu.Unlock()
}
}
// SendToUser delivers msg directly to the client identified by userID.
// Returns true if the client was found and the message was queued.
func (h *Hub) SendToUser(userID int64, msg []byte) bool {
+11 -5
View File
@@ -53,14 +53,20 @@ func (h *Hub) SetEventStore(s EventStore) {
h.eventStore.Store(&s)
}
// SetPluginRegistry wires the plugin.Registry so the hub can dispatch
// slash commands (chat_command messages) to plugin-owned handlers.
// Pass nil to disable plugin command dispatch. Must be called before Run;
// late calls are ignored with an error log.
func (h *Hub) SetPluginRegistry(r *plugin.Registry) {
if h.rejectIfRunning("SetPluginRegistry") {
return
}
h.pluginRegistry = r
}
// SetPluginEventSink wires the plugin.EventSink so the hub fans out each
// sequenced broadcast to subscribed plugins. Pass nil to disable. Safe to
// call at any time, including after Run has started.
//
// Deliberately still a setter after B3-4: the sink consumes the built hub's
// broadcaster (sink.SetBroadcaster(hub.BroadcastToChannel)), so it cannot
// exist before the hub does — a genuine two-phase wire, unlike the registry,
// which is a HubOptions field.
func (h *Hub) SetPluginEventSink(s *plugin.EventSink) {
h.pluginSink.Store(s)
}
+19 -5
View File
@@ -5,9 +5,18 @@ import (
"fmt"
)
// SetLiveKit sets the LiveKit client on the hub. Must be called before Run;
// late calls are ignored with an error log.
func (h *Hub) SetLiveKit(lk *LiveKitClient) {
if h.rejectIfRunning("SetLiveKit") {
return
}
h.livekit = lk
}
// GenerateToken delegates to the LiveKit client. Returns an error if LiveKit
// is not configured (HubOptions.LiveKit was nil). Satisfies
// VoiceTokenGenerator so the Hub can be passed as a dep at registration time.
// is not configured. Satisfies VoiceTokenGenerator so the Hub can be passed
// as a dep at registration time (before SetLiveKit is called).
func (h *Hub) GenerateToken(userID int64, username string, channelID int64, voiceJoinToken string, canPublish, canSubscribe, canVideo, canScreenShare bool) (string, error) {
if h.livekit == nil {
return "", fmt.Errorf("voice not configured")
@@ -34,6 +43,11 @@ func (h *Hub) LiveKitHealthCheck(ctx context.Context) (bool, error) {
return h.livekit.HealthCheck(ctx)
}
// The LiveKit process manager arrives via HubOptions.LiveKitProcess (B3-4);
// its only hub consumer is the voice_join guard reading IsRunning to fail
// closed while the supervised SFU is down.
// SetLiveKitProcess sets the LiveKit process manager on the hub. Must be
// called before Run; late calls are ignored with an error log.
func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) {
if h.rejectIfRunning("SetLiveKitProcess") {
return
}
h.lkProcess = p
}
-185
View File
@@ -1,185 +0,0 @@
package ws
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/plugin"
"github.com/J3vb/OwnCord/Server/service"
)
// HubOptions carries everything a Hub needs before Run starts (S-11 / B3-4).
// The four pre-Run setters this struct replaced (SetLiveKit,
// SetLiveKitProcess, SetPluginRegistry, ConfigureReplay) were all guarded by
// rejectIfRunning — construction-phase wiring pretending to be mutable state.
// What genuinely IS mutable after Run stays a setter: the event persister,
// event store and plugin event sink are atomic hot-swaps that the app wires
// after the dispatch loop starts (internal/app/persistence.go), and
// SetPendingVoiceModFlags is per-user runtime state.
type HubOptions struct {
// DB and Limiter are required: every dispatch path reads the database,
// and the handler deps capture the limiter at registration. NewHub
// refuses to build a Hub without them.
DB *db.DB
Limiter *auth.RateLimiter
// Services is the domain layer V2 handlers delegate to. Production
// always passes it; nil is the degraded fixture many ws tests build,
// where handlers keep their direct-DB fallback paths.
Services *service.Services
// Settings is required: the hub's settings cache (server name and MOTD
// on every auth_ok) reads through it instead of the raw handle — the
// B3-8 settings family owns those reads. Production passes
// Services.Settings; test helpers default it over the test database.
Settings SettingsReader
// LiveKit is the voice token signer; nil means voice is not configured
// and every voice join is refused. LiveKitProcess is the supervised
// companion SFU — it requires LiveKit, because a process no client can
// sign tokens for is unusable, and the voice_join guard reads a non-nil
// process's IsRunning to fail closed while it is down.
LiveKit *LiveKitClient
LiveKitProcess *LiveKitProcess
// PluginRegistry enables plugin slash-command dispatch; nil disables it.
// The plugin event sink is NOT here: it consumes the built hub's
// broadcaster, so it stays the two-phase SetPluginEventSink.
PluginRegistry *plugin.Registry
// Replay budget (event_persistence.replay_ring_size / replay_cold_limit).
// Zero keeps the compiled-in defaults; negative is refused rather than
// silently ignored.
ReplayRingSize int
ReplayColdLimit int
}
// NewHub creates a Hub ready to be started with Run, validating that the
// required collaborators are present — before B3-4, construction always
// succeeded and a missing collaborator surfaced as a later panic or a
// silently refused setter call. It also initializes the settings cache from
// the database. If opts.Services is non-nil, V2 handlers receive service
// references for business logic delegation.
func NewHub(opts HubOptions) (*Hub, error) {
if opts.DB == nil {
return nil, errors.New("ws: HubOptions.DB is required (every dispatch path reads it)")
}
if opts.Limiter == nil {
return nil, errors.New("ws: HubOptions.Limiter is required (handler deps capture it at registration)")
}
if opts.Settings == nil {
return nil, errors.New("ws: HubOptions.Settings is required (the settings cache reads through it)")
}
if opts.LiveKitProcess != nil && opts.LiveKit == nil {
return nil, errors.New("ws: HubOptions.LiveKitProcess without LiveKit — a supervised SFU no client can sign tokens for")
}
if opts.ReplayRingSize < 0 || opts.ReplayColdLimit < 0 {
return nil, fmt.Errorf("ws: negative replay budget (ring %d, cold %d)", opts.ReplayRingSize, opts.ReplayColdLimit)
}
database, limiter, svc := opts.DB, opts.Limiter, opts.Services
settingsReader := opts.Settings
ringSize := 1000
if opts.ReplayRingSize > 0 {
ringSize = opts.ReplayRingSize
}
reg := NewHandlerRegistry()
h := &Hub{
clients: make(map[int64]*Client),
db: database,
limiter: limiter,
settings: settingsReader,
broadcast: make(chan broadcastMsg, 1024),
clientEvents: make(chan clientEvent, 64),
stop: make(chan struct{}),
pubsub: NewPubSub(),
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
replayBuf: NewEventRingBuffer(ringSize),
registry: reg,
permChecker: permissions.NewChecker(database),
settingsName: "OwnCord Server",
settingsMotd: "Welcome!",
voiceKeyHolders: make(map[int64]int64),
fatalFn: func() { os.Exit(1) },
livekit: opts.LiveKit,
lkProcess: opts.LiveKitProcess,
pluginRegistry: opts.PluginRegistry,
}
if opts.ReplayColdLimit > 0 {
h.coldReplayLimit = opts.ReplayColdLimit
}
// V2 handler registrations (need Hub fields for deps).
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
chatDeps := ChatDeps{
Limiter: h.limiter,
}
presenceDeps := PresenceDeps{
Limiter: h.limiter,
}
reactionDeps := ReactionDeps{}
callDeps := CallDeps{Limiter: h.limiter}
if svc != nil {
chatDeps.MessageSvc = svc.Messages
presenceDeps.ChannelSvc = svc.Channels
reactionDeps.MessageSvc = svc.Messages
callDeps.DMSvc = svc.DMs
h.messageSvc = svc.Messages
h.perms = svc.Permissions
// So @here's offline narrowing can tell a disconnected idle/dnd reader
// (users.status keeps their last *chosen* value across a disconnect)
// from one who is actually still connected — the same live-connection
// rule presentableMembers applies to the members array.
svc.Messages.SetOnlineChecker(h.IsUserConnected)
// So every DM payload DMService builds (GET/POST /dms, POST
// /dms/group, PATCH /dms/{id}, and every broadcastDMOpen refresh)
// applies the same live-connection rule instead of only the ready
// payload's presentableDMChannels doing so (OC-0304).
svc.DMs.SetOnlineChecker(h.IsUserConnected)
}
registerChatHandlers(reg, chatDeps)
registerPresenceHandlers(reg, presenceDeps)
registerReactionHandlers(reg, reactionDeps)
registerCallHandlers(reg, callDeps)
// Phase C Step 9 — plugin slash commands. The registry closure predates
// B3-4 (the registry used to arrive via a post-construction setter); it
// stays a closure for the nil-interface reason below. MessageSvc gates
// broadcasts.
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
// A nil registry must yield a nil interface, not a typed-nil
// *plugin.Registry — the handler's "no plugins loaded" check is an
// interface comparison.
Registry: func() CommandDispatcher {
if h.pluginRegistry == nil {
return nil
}
return h.pluginRegistry
},
MessageSvc: h.messageSvc,
Limiter: h.limiter,
})
registerVoiceControlsV2(reg, VoiceDeps{
DB: h.db,
Limiter: h.limiter,
Permissions: h.permChecker,
PermSvc: h.perms,
LiveKit: h.livekit,
TokenGen: h, // Hub delegates to h.livekit at call time
KeyHolder: h,
Mod: h,
})
h.refreshSettingsLocked(context.Background())
return h, nil
}
-153
View File
@@ -1,153 +0,0 @@
package ws
import (
"time"
"github.com/J3vb/OwnCord/Server/db"
)
// pendingPresence is the coalescer's latest-wins entry for one user.
type pendingPresence struct {
status string
customStatus *string
}
// QueuePresence coalesces connect/disconnect presence broadcasts: the latest
// state per user is buffered for presenceCoalesceWindow and then flushed via
// BroadcastPresence. Each un-coalesced presence change is a sequenced global
// broadcast — an O(connected clients) fan-out under seqMu — so a reconnect
// storm (proxy blip, deploy, network hiccup) used to fire O(users) of them
// from the connect critical path all at once. Latest-wins is exactly
// presence's semantics: a flap inside the window collapses to its final
// state, and the flushed frames are ordinary sequenced presence messages, so
// the wire format and replay behaviour are unchanged. User-chosen status
// changes (presence_update handler) do not pass through here.
func (h *Hub) QueuePresence(userID int64, status string, customStatus *string) {
h.presenceMu.Lock()
if h.presenceQueue == nil {
h.presenceQueue = make(map[int64]pendingPresence)
}
h.presenceQueue[userID] = pendingPresence{status: status, customStatus: customStatus}
armed := h.presenceFlushArmed
h.presenceFlushArmed = true
h.presenceMu.Unlock()
if !armed {
time.AfterFunc(presenceCoalesceWindow, h.flushPresenceQueue)
}
}
// dropQueuedPresenceAndBroadcast atomically removes any coalesced presence
// still queued for userID and runs broadcast, both under presenceMu. Called
// when a fresher presence for that user is delivered directly (the
// presence_update handler path, via EmitEvents), so the delete and the send
// of the fresher frame can never straddle flushPresenceQueue's own
// snapshot-and-broadcast critical section (OC-0005).
//
// Holding presenceMu across the delete AND the broadcast — rather than just
// the delete — is what actually closes the race: whichever of this call and
// presenceCoalesceWindow is how long QueuePresence buffers connect/disconnect
// presence before flushing. Long enough to collapse a socket flap
// (disconnect+reconnect through a proxy blip) into one frame, short enough
// that a genuine arrival still looks immediate to humans.
const presenceCoalesceWindow = 300 * time.Millisecond
// presenceFlushRaceHook, when non-nil, runs once per flushPresenceQueue call
// immediately after the coalesced queue has been snapshotted and cleared,
// while presenceMu is still held. Test-only (always nil in production): the
// snapshot-to-broadcast window is too narrow to land a real concurrent
// dropQueuedPresenceAndBroadcast reliably, so tests use this hook to
// reproduce that interleaving deterministically. Mirrors the established
// refreshChannelVisibilityRaceHook / voiceJoinPostTokenRaceHook pattern.
//
// It is handed the Hub being flushed, and a test that installs it MUST
// ignore calls for any other Hub. The hook is package-global but flushes are
// per-Hub, and QueuePresence's AfterFunc outlives the test that armed it:
// dropQueuedPresenceAndBroadcast clears the queue without disarming the
// timer, so a sibling test's 300ms flush fires long after that test returned
// — into whatever hook is installed by then. Passing the Hub is what lets the
// installer tell its own flush from that stray one; without it, a hook body
// that is only safe to run once (closing a channel, say) panics.
var presenceFlushRaceHook func(*Hub)
// flushPresenceQueue acquires presenceMu second also enqueues its broadcast
// second.
// - If this call goes first, it deletes the entry before flush can ever
// snapshot it, so flush never broadcasts the stale state at all.
// - If flush goes first, this call's delete is a no-op against the
// already-cleared queue, but its broadcast still cannot run until flush's
// own broadcast has already been enqueued — so the fresher frame is
// stamped with the higher seq by deliverBroadcast's single FIFO consumer
// and every client's final view converges on it, not the stale one.
//
// broadcast runs with presenceMu held: every current caller (BroadcastToAll,
// BroadcastToAllExcept) only enqueues onto h.broadcast's non-blocking
// channel send, so this cannot block and introduces no new lock-order edge.
// Both callers sharing that same channel also means the "enqueues second"
// ordering guarantee above translates directly into delivery order: both
// broadcasts are drained by the same single-consumer hub dispatch loop
// (deliverBroadcast), in the order they were enqueued.
func (h *Hub) dropQueuedPresenceAndBroadcast(userID int64, broadcast func()) {
h.presenceMu.Lock()
defer h.presenceMu.Unlock()
delete(h.presenceQueue, userID)
broadcast()
}
// flushPresenceQueue drains the coalescer and broadcasts each user's latest
// presence, all under presenceMu (OC-0005). Runs on the AfterFunc timer
// goroutine.
//
// presenceMu is held across the broadcast loop, not just the snapshot: it
// used to be released beforehand, which let a concurrent
// dropQueuedPresenceAndBroadcast (nee dropQueuedPresence) call race in after
// the snapshot had already escaped the lock. The drop was then a guaranteed
// no-op against the live (already-nilled) map, AND nothing constrained
// whether that call's own fresher broadcast landed on h.broadcast before or
// after this loop's stale one — so the stale connect-time presence could win
// the seq race and permanently overwrite a user-chosen status. Holding the
// lock here forces the two critical sections to serialize, which is what
// dropQueuedPresenceAndBroadcast's ordering guarantee depends on.
func (h *Hub) flushPresenceQueue() {
h.presenceMu.Lock()
defer h.presenceMu.Unlock()
queued := h.presenceQueue
h.presenceQueue = nil
h.presenceFlushArmed = false
if presenceFlushRaceHook != nil {
presenceFlushRaceHook(h)
}
for uid, p := range queued {
h.BroadcastPresence(uid, p.status, p.customStatus)
}
}
// BroadcastPresence fans a presence change out with the invisible mapping
// applied: everyone else sees db.BroadcastStatus(status), the user themselves
// sees the truth. It is the non-handler counterpart of presenceEvents, used by
// the connect and disconnect paths (via the QueuePresence coalescer, which
// delivers through here).
func (h *Hub) BroadcastPresence(userID int64, status string, customStatus *string) {
public := db.BroadcastStatus(status)
if public == status {
h.BroadcastToAll(buildPresenceMsg(userID, status, customStatus))
return
}
// The public frame's status already collapsed to db.BroadcastStatus, but
// customStatus does not: passing it through verbatim would tell every
// other client an "offline" member's real free-text status, which is a
// tell that they are actually online. Blank it explicitly (not omitted —
// presencePayload.CustomStatus has no omitempty) so the client clears any
// cached text, matching what db.MemberSummary.ForViewer already does for
// the ready payload's member list.
//
// Normal priority, excluding the owner (BroadcastToAllExcept), not
// broadcastExcludeLow: the low-priority queue is unsequenced and dropped
// (not disconnected) on overflow, so it could silently lose this frame
// with no replay recovery, and — since writePump always drains normal
// strictly before low — deliver it out of order against the very
// connect/disconnect presence frames this same coalescer flush also
// produces for other users via BroadcastToAll (OC-0003).
h.BroadcastToAllExcept(userID, buildPresenceMsg(userID, public, nil))
h.SendToUser(userID, buildPresenceMsg(userID, status, customStatus))
}
@@ -44,7 +44,7 @@ func TestRefreshChannelVisibility_ReconnectDuringFanOutActsOnLiveClient(t *testi
// Bare hub (svc=nil): h.perms is nil, so RefreshChannelVisibility takes the
// GetUserByID+GetRoleByID branch this test targets.
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
user, err := database.GetUserByID(ctx, uid)
if err != nil || user == nil {
-253
View File
@@ -1,253 +0,0 @@
package ws
import "log/slog"
// Register queues a client for registration with the hub.
func (h *Hub) Register(c *Client) {
h.clientEvents <- clientEvent{c: c, add: true}
}
// Unregister queues a client for removal from the hub.
func (h *Hub) Unregister(c *Client) {
h.clientEvents <- clientEvent{c: c}
}
// registerNow adds c to the hub and subscribes it to its topics.
//
// readableChannelIDs is the set of channels the user holds READ_MESSAGES on,
// as computed by the handshake (serve.go). It gates the inherited voice-channel
// subscription only; a nil set denies it (fail closed).
//
// Replacing an existing connection strips its subscriptions (UnsubscribeAll)
// and re-subscribes the new one (Subscribe) as two separate PubSub-lock
// acquisitions — back to back, but not atomic. A caller that must not lose a
// broadcast concurrently racing the replacement (i.e. one deliverBroadcast
// could deliver in the gap between those two acquisitions) has to call this
// while holding h.seqMu, the same lock deliverBroadcast holds for its entire
// critical section (seq allocation, replay-buffer push, and publish) — that
// serializes the two entirely, rather than merely narrowing the window. See
// serve.go's handleReconnect, which re-reads the replay tail and calls
// registerNow inside one h.seqMu section for exactly this reason.
func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) {
// Voice channel the replaced connection was in, if any. Re-elected below,
// after the hub lock is released.
var replacedVoiceChID int64
h.mu.Lock()
if old, exists := h.clients[c.userID]; exists {
oldE2EEKey, oldE2EESig := old.getE2EEPubKey()
oldVoiceChID, oldVoiceJoinToken, oldVoiceJoinCompleted := old.clearVoiceState()
replacedVoiceChID = oldVoiceChID
// A moderator-imposed mute/deafen stashed by voice_mod_move
// (setPendingModFlags) lives ONLY on the old *Client between the
// target's eviction (which deletes the voice_states row that state
// normally lives in) and the target's own re-join, which consumes it
// via takePendingModFlags (voice_join.go). Any client replacement —
// reconnect or full resync alike — must carry it to the new *Client
// or it is silently destroyed and the mute is lost (OC-0302).
// Unlike the voice-state transfer below, this has none of the
// voiceJoinCompleted supersession concerns, so it is not gated on
// c.lastSeq > 0: take-and-clear leaves nothing behind for old to
// double-serve, and a stash nobody set is always (false, false).
if pendingMuted, pendingDeafened := old.takePendingModFlags(); pendingMuted || pendingDeafened {
c.setPendingModFlags(pendingMuted, pendingDeafened)
}
if c.lastSeq > 0 {
// Network reconnect — preserve voice state so the user stays
// in voice during brief WS drops.
//
// Gated on oldVoiceJoinCompleted (OC-0270): a join that
// voiceJoinPersist has merely committed to the DB and set on the
// old client, but that voiceJoinComplete has not yet finished, is
// still racing its own supersession guards in voice_join.go
// (voice_join.go:423, :470) — both compare the old client's live
// voiceChID/voiceJoinToken against the values captured when the
// join started. Clearing the old client's state above as part of
// this very transfer makes those guards read as "superseded" and
// abort the join (no token delivered, no voice_state broadcast,
// no VoiceTopic subscribe) — while the DB row and the new
// client's transferred state still agree, so sweepStaleVoiceStates
// never reaps it. Transferring only a completed join avoids
// resurrecting exactly that half-finished state; an incomplete
// one instead leaves the new client with voiceChID 0, so the
// still-committed row now disagrees with hub state and the next
// sweep tick reaps it, letting the user rejoin.
if c.getVoiceChID() == 0 && oldVoiceJoinCompleted {
c.setVoiceState(oldVoiceChID, oldVoiceJoinToken)
// c.setVoiceState above resets the fresh-join-in-progress flag
// it defaults to; restore it since we just verified the old
// client's join over this same (chID, token) had completed.
c.markVoiceJoinCompleteIfMatch(oldVoiceChID, oldVoiceJoinToken)
// The announced ECDH key must survive with the voice state:
// the client keeps its keypair across a WS blip and only
// re-announces on a LiveKit-room reconnect, so without the
// transfer voice_join replays nothing for this user and new
// joiners' key exchanges time out.
c.setE2EEPubKey(oldE2EEKey, oldE2EESig)
}
// The focused channel must transfer too: the client never
// re-sends channel_focus on a resume (mountChannel early-returns
// on the same channel), so without it the ChannelTopic
// re-subscribe below is a no-op and the message stream dies
// silently. READ-gated like every ChannelTopic subscription;
// a nil set denies (fail closed).
if oldChID := old.getChannelID(); oldChID != 0 &&
c.getChannelID() == 0 && readableChannelIDs[oldChID] {
c.mu.Lock()
c.channelID = oldChID
c.mu.Unlock()
}
}
// Fresh connections (lastSeq == 0): do NOT transfer voice state.
// Stale voice cleanup (DB + broadcast + LiveKit) is owned entirely
// by the handshake path in serve.go, which runs before registerNow.
// registerNow only handles in-memory client replacement.
// Kick the stale connection atomically before registering
// the new one — prevents TOCTOU races on duplicate login.
// closeSend MUST precede UnsubscribeAll: Subscribe refuses clients
// whose send is closed, so this ordering leaves the old connection's
// in-flight handlers no window to re-take a stripped topic.
slog.Warn("hub: kicking stale connection for re-registering user",
"user_id", c.userID, "last_seq", c.lastSeq)
old.closeSend()
// Remove the old client from all pub/sub topics before replacing.
h.pubsub.UnsubscribeAll(old)
}
h.clients[c.userID] = c
// Subscribe the new client to its default pub/sub topics immediately
// after UnsubscribeAll(old) above, with nothing in between.
//
// This does NOT make strip+resubscribe atomic, and must not be read as
// doing so: the two are separate ps.mu acquisitions, and PublishGlobal
// takes ps.mu alone (never h.mu), so a deliverBroadcast landing between
// them still finds no subscriber for this user. That frame is
// unrecoverable — its seq was already allocated and pushed to the replay
// buffer, the resuming client's replay snapshot was taken even earlier,
// and the client tracks only max(seq), so the next frame silently
// advances past the hole. Only a caller holding h.seqMu closes that
// window; see this function's doc comment and serve.go's handleReconnect.
//
// What the ordering does buy is the smallest possible gap for the callers
// that cannot hold seqMu — the fresh-connect path, whose buildReady
// rebuilds state from the DB afterwards, and the clientEvents path, which
// runs on the hub goroutine and so cannot race deliverBroadcast at all.
// The registration log line (a syscall-backed slog call) and
// updateKeyHolder (keyHolderMu plus a full h.clients scan under
// h.mu.RLock) both used to sit in that gap; both now run after the
// subscribes. Keeping the subscribes under h.mu is incidental but free:
// pubsub uses its own independent lock and never calls back into the hub,
// so h.mu → ps.mu adds no lock-ordering risk.
h.pubsub.Subscribe(c, TopicGlobal)
h.pubsub.Subscribe(c, UserTopic(c.userID))
// If the client already has a focused channel (e.g. test clients created with
// NewTestClientWithChannel, or reconnecting clients), subscribe immediately so
// deliverBroadcast can reach them without waiting for a channel_focus message.
if chID := c.getChannelID(); chID != 0 {
h.pubsub.Subscribe(c, ChannelTopic(chID))
}
// If the client is already in a voice channel (e.g. reconnect), restore its
// subscriptions without a new voice_join (a same-channel rejoin is rejected
// with ALREADY_JOINED) or channel_focus.
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
// VoiceTopic is the only transport for voice_e2ee_announce relays and
// carries nothing else, for a channel the user already joined via the
// CONNECT_VOICE-gated voice_join — so no READ gate.
h.pubsub.Subscribe(c, VoiceTopic(voiceChID))
// Voice membership is gated on CONNECT_VOICE alone, so it must not by
// itself grant a channel's message stream: subscribe only when the
// handshake confirmed READ_MESSAGES on that channel.
if readableChannelIDs[voiceChID] {
h.pubsub.Subscribe(c, ChannelTopic(voiceChID))
}
}
total := len(h.clients)
h.mu.Unlock()
slog.Info("hub: client registered", "user_id", c.userID, "total_clients", total)
// A fresh connect (lastSeq == 0) drops the replaced connection's voice state
// without transferring it, so that channel just lost a participant and the
// E2EE key holder may need to move. handleVoiceLeave never runs on this path
// — readPump skips it when replaced, and it early-returns on already-cleared
// state — so re-elect here. Must be outside h.mu: updateKeyHolder takes
// keyHolderMu and then h.mu.RLock. The recompute reads live client voice
// state, so it is idempotent and also correct when the state was transferred.
// It runs after the subscribe block above; updateKeyHolder only reads
// h.clients' voice state and writes voiceKeyHolders, so it has no
// ordering dependency on pub/sub subscriptions.
if replacedVoiceChID != 0 {
h.updateKeyHolder(replacedVoiceChID)
}
// Re-sync this connection's local E2EE peer-key map now that it is
// reachable (OC-0276). voice_e2ee_announce is delivered as an
// unsequenced pub/sub frame (sendToVoiceChannelExcept, voice_e2ee.go),
// bypassing deliverBroadcast/h.replayBuf entirely — so on a network
// reconnect (the transfer above), neither reconnect replay tier can ever
// redeliver a peer's key, or a mid-call key rotation, that was announced
// while this socket was down. voiceJoinComplete's relay
// (voice_join.go) only runs on a brand-new voice_join, never here, so
// without this call a resumed connection's peer-key map would silently
// and permanently desync from its (correctly replayed) voice roster.
// c.getVoiceChID() reflects the transfer above, so this covers a
// resumed connection as well as a client pre-set into a voice channel
// (e.g. NewTestClientWithChannel); it is a no-op whenever c is not
// currently in a voice channel, which is the common case (fresh login).
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
h.sendVoicePeerKeys(c, voiceChID)
// Re-relay THIS client's own stored key back onto VoiceTopic (OC-0316).
// voice_e2ee_offer (the room-key-bearing message) is a targeted,
// unsequenced send that is silently dropped if this socket was down
// when it went out (sendToUserIfInVoiceChannel, voice_e2ee.go) — and
// unlike voice_e2ee_announce it has no reconnect-replay recovery
// path either. A key rotation sent during the outage otherwise
// strands this client on a dead key with no signal and no retry
// until the key holder's next periodic rotation. The client's
// duplicate-announce handling already re-wraps and re-offers the
// CURRENT room key whenever it sees a peer announce a key it
// already knows, so re-announcing our own (unchanged) key is enough
// to make the key holder re-offer — no client change needed.
if key, sig := c.getE2EEPubKey(); key != "" {
h.sendToVoiceChannelExcept(voiceChID, c.userID, buildVoiceE2EEAnnounce(c.userID, key, sig))
}
}
}
func (h *Hub) unregisterNow(c *Client) bool {
h.mu.Lock()
current, exists := h.clients[c.userID]
if exists && current == c {
delete(h.clients, c.userID)
slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients))
h.mu.Unlock()
h.pubsub.UnsubscribeAll(c)
return false // not replaced
}
h.mu.Unlock()
// exists means a *different* client holds the slot — a genuine replacement,
// whose teardown must not mark the live connection's user offline. An absent
// entry means this client was already kicked (every kick path deletes it via
// kickClient), which is a real disconnect and still needs the offline
// presence broadcast and voice cleanup in readPump's defer.
return exists
}
// shouldMarkOffline reports whether a disconnect teardown should run
// MarkUserDisconnected and broadcast an offline presence for c's user.
//
// `replaced` (unregisterNow's return, sampled once at the start of teardown)
// is necessary but not sufficient: both readPump's defer and
// unregisterFailedHandshake sample it BEFORE handleVoiceLeave, which can
// block for seconds (DB delete, audience scan, a LiveKit call bounded by
// lkTimeout=5s). A reconnect landing during that window registers a new
// client for the same user and is invisible to the stale boolean, so the
// dead connection's teardown would otherwise mark the live session offline
// (OC-0019). Re-checking h.clients at decision time closes that gap: any
// entry present once c has been removed is necessarily a newer connection —
// unregisterNow only ever deletes c's own slot, never someone else's.
func (h *Hub) shouldMarkOffline(c *Client, replaced bool) bool {
return !replaced && h.GetClient(c.userID) == nil
}
-50
View File
@@ -1,50 +0,0 @@
package ws
import (
"context"
"time"
)
// SettingsReader is the hub's view of the settings family: one keyed read.
// service.SettingsService satisfies it in production; test helpers back it
// with a service over the test database. Defined on the consumer side so ws
// depends on the capability, not the provider.
type SettingsReader interface {
Setting(ctx context.Context, key string) (string, error)
}
// getCachedSettings returns server_name and motd, refreshing the cache if stale.
func (h *Hub) getCachedSettings(ctx context.Context) (string, string) {
h.settingsMu.RLock()
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
name, motd := h.settingsName, h.settingsMotd
h.settingsMu.RUnlock()
return name, motd
}
h.settingsMu.RUnlock()
h.settingsMu.Lock()
defer h.settingsMu.Unlock()
// Double-check after acquiring write lock.
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
return h.settingsName, h.settingsMotd
}
h.refreshSettingsLocked(ctx)
return h.settingsName, h.settingsMotd
}
// refreshSettingsLocked reloads server_name and motd through the settings
// reader. Caller must hold settingsMu (write lock) or call during init.
func (h *Hub) refreshSettingsLocked(ctx context.Context) {
// The refresh serves the hub-wide settings cache, not the connection that
// happened to trigger it — a dying connection's ctx must not fail the
// fetches (the TTL stamp below would then pin stale values for 30s).
ctx = context.WithoutCancel(ctx)
if name, err := h.settings.Setting(ctx, "server_name"); err == nil {
h.settingsName = name
}
if motd, err := h.settings.Setting(ctx, "motd"); err == nil {
h.settingsMotd = motd
}
h.settingsLastUpdate = time.Now()
}
+2 -2
View File
@@ -249,8 +249,8 @@ func simDMChannel(i, j int) int64 {
}
func runHubSim(t *testing.T, seed uint64, steps int) (stats map[string]int, raced int) {
database := openTestDB(t)
hub := newTestHubWith(t, ws.HubOptions{DB: database, ReplayRingSize: simRing})
hub, database := newTestHub(t)
hub.ConfigureReplay(simRing, 0)
hub.FreezeTopicLimiterForTest()
s := &sim{
t: t, hub: hub, seed: seed, steps: steps,
+2 -2
View File
@@ -46,7 +46,7 @@ func TestSweepStaleVoiceStates_JoinCatchesUpDuringDeleteWindow(t *testing.T) {
t.Fatalf("GetVoiceState: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
c := NewTestClient(h, uid, make(chan []byte, 8))
h.clients[uid] = c
// c.voiceChID is still 0 here — exactly like the joiner's client at the
@@ -105,7 +105,7 @@ func TestCleanupVoiceForChannel_NotifiesReadAudienceOfArchivedChannel(t *testing
t.Fatalf("JoinVoiceChannel(B): %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
cA := NewTestClient(h, userA, make(chan []byte, 8))
cB := NewTestClient(h, userB, make(chan []byte, 8))
// bystander has READ_MESSAGES on the channel (harvestVoiceRoleID grants it
+3 -3
View File
@@ -116,7 +116,7 @@ func TestSweepStaleVoiceStates_TransientPermissionErrorDoesNotEvict(t *testing.T
t.Fatalf("JoinVoiceChannel: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
c := NewTestClient(h, uid, make(chan []byte, 8))
c.setVoiceState(chID, "tok")
h.clients[uid] = c
@@ -170,7 +170,7 @@ func TestSweepStaleVoiceStates_GhostRemovalReelectsKeyHolder(t *testing.T) {
t.Fatalf("JoinVoiceChannel(ghost): %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
survivor := NewTestClient(h, survivorUID, make(chan []byte, 8))
survivor.setVoiceState(chID, "tok-survivor")
h.clients[survivorUID] = survivor
@@ -225,7 +225,7 @@ func TestCleanupVoiceForChannel_ConcurrentJoinNotClobbered(t *testing.T) {
t.Fatalf("JoinVoiceChannel: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
c := NewTestClient(h, uid, make(chan []byte, 8))
h.clients[uid] = c
c.setVoiceState(chA, "tok-a")
+10 -43
View File
@@ -12,7 +12,6 @@ import (
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/service"
"github.com/J3vb/OwnCord/Server/ws"
)
@@ -39,7 +38,7 @@ func newTestHub(t testing.TB) (*ws.Hub, *db.DB) {
t.Helper()
database := openTestDB(t)
limiter := auth.NewRateLimiter()
hub := newTestHubDeps(t, database, limiter, nil)
hub := ws.NewHub(database, limiter, nil)
return hub, database
}
@@ -670,34 +669,10 @@ func assertNotReceived(t *testing.T, ch <-chan []byte, label string) {
// ─── LiveKit lifecycle ────────────────────────────────────────────────────────
// TestHub_NilLiveKitOption: a hub built without LiveKit (voice not
// configured) constructs fine and refuses token generation. Replaces the
// pre-B3-4 SetLiveKit nil-safety test — the setter no longer exists.
func TestHub_NilLiveKitOption(t *testing.T) {
func TestHub_SetLiveKit_NilSafe(t *testing.T) {
hub, _ := newTestHub(t)
if _, err := hub.GenerateToken(1, "u", 1, "", false, false, false, false); err == nil {
t.Fatal("GenerateToken on a voiceless hub must error")
}
}
// TestNewHub_RequiredCollaborators pins B3-4's validation: before it,
// construction always succeeded (api tests built ws.NewHub(nil, nil, nil)
// hubs) and a missing collaborator surfaced as a later panic.
func TestNewHub_RequiredCollaborators(t *testing.T) {
if _, err := ws.NewHub(ws.HubOptions{}); err == nil {
t.Fatal("NewHub without DB must error")
}
database := openTestDB(t)
if _, err := ws.NewHub(ws.HubOptions{DB: database}); err == nil {
t.Fatal("NewHub without Limiter must error")
}
if _, err := ws.NewHub(ws.HubOptions{DB: database, Limiter: auth.NewRateLimiter()}); err == nil {
t.Fatal("NewHub without a Settings reader must error")
}
settings := service.NewSettingsService(database)
if _, err := ws.NewHub(ws.HubOptions{DB: database, Limiter: auth.NewRateLimiter(), Settings: settings, ReplayRingSize: -1}); err == nil {
t.Fatal("NewHub with a negative replay ring must error")
}
// Setting a nil LiveKit client must not panic.
hub.SetLiveKit(nil)
}
// ─── GracefulStop ─────────────────────────────────────────────────────────────
@@ -969,21 +944,13 @@ func TestHub_LiveKitHealthCheck_NilReturnsError(t *testing.T) {
}
}
// ─── LiveKitProcess option ──────────────────────────────────────────────────
// ─── SetLiveKitProcess ──────────────────────────────────────────────────────
// TestHub_LiveKitProcessRequiresClient pins B3-4's coherence rule: a
// supervised process without a client is refused at construction — it used
// to be a silently accepted setter call on a hub that could sign no tokens.
func TestHub_LiveKitProcessRequiresClient(t *testing.T) {
database := openTestDB(t)
_, err := ws.NewHub(ws.HubOptions{
DB: database,
Limiter: auth.NewRateLimiter(),
LiveKitProcess: &ws.LiveKitProcess{},
})
if err == nil {
t.Fatal("NewHub must refuse LiveKitProcess without LiveKit")
}
func TestHub_SetLiveKitProcess(t *testing.T) {
hub, _ := newTestHub(t)
hub.SetLiveKitProcess(nil)
go hub.Run()
hub.GracefulStop()
}
// ─── VoiceSessionCount ─────────────────────────────────────────────────────
-487
View File
@@ -1,487 +0,0 @@
package ws
import (
"context"
"fmt"
"log/slog"
"sync/atomic"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
)
// channelReadAudience returns the connected user IDs whose current role may READ
// channelID. Always non-nil, so an empty result means "deliver to nobody"
// rather than "no filter". Each user's verdict comes from the cached
// PermissionService when the hub has one (one in-memory lookup per connected
// user; a miss repopulates from the user's CURRENT role, so a mid-session
// reassignment is still honored). Caching is safe here because revocation is
// delivered synchronously at every mutation site: a role change calls
// InvalidateUser (admin/handlers_users.go) and a channel-override change calls
// InvalidateAll (admin/handlers_channel_perms.go) before the hub fan-out runs,
// with the 30s cache TTL as a backstop; the F6 gen-counter guard in the service
// prevents a populate racing an invalidation from caching stale data. Fails
// closed: a client whose role cannot be resolved is left out. Bare test hubs
// without a service fall back to live per-call lookups, memoised for the
// duration of the call. Mirrors RefreshChannelVisibility, which resolves
// visibility the same way.
func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 {
return h.channelReadAudienceImpl(ctx, channelID, false)
}
// channelReadAudienceIgnoringArchived is channelReadAudience without the
// Archived short-circuit (OC-0022). CleanupVoiceForChannel's only two
// callers (admin/handlers_channels.go's archive and delete paths) always
// commit archived=1 to the channel before evicting its voice participants —
// deliberately, per admin/api_test.go's
// TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup, so a concurrent
// voice_join sees the archived gate. That means channelReadAudience's own
// Archived check, evaluated from CleanupVoiceForChannel, always sees the
// channel already archived and always returns nobody: the voice_leave that
// should tell every bystander who could see the room a moment ago that the
// call ended never reaches them, only the evicted participants themselves
// (added back by CleanupVoiceForChannel's own loop). This resolves that same
// pre-archival READ audience for exactly that one broadcast, leaving every
// other channelReadAudience call site (and its archived-channel behavior)
// untouched.
func (h *Hub) channelReadAudienceIgnoringArchived(ctx context.Context, channelID int64) []int64 {
return h.channelReadAudienceImpl(ctx, channelID, true)
}
func (h *Hub) channelReadAudienceImpl(ctx context.Context, channelID int64, ignoreArchived bool) []int64 {
h.mu.RLock()
userIDs := make([]int64, 0, len(h.clients))
for uid := range h.clients {
userIDs = append(userIDs, uid)
}
h.mu.RUnlock()
// A DM channel carries no channel_overrides rows, so every connected
// user whose base role holds READ_MESSAGES would otherwise pass the role
// scan below — leaking a private DM call's voice_state/voice_leave
// events to the whole server. Resolve the DM's real audience (its
// participants, intersected with who is actually connected) instead,
// mirroring the IsDMParticipant membership rule hasChannelAccess uses.
var ref permissions.ChannelRef
if h.db != nil {
ch, err := h.db.GetChannel(ctx, channelID)
if err != nil {
// Fail closed: an unresolvable channel must not fall through to
// the role scan, which would treat it as a readable non-DM channel.
slog.Error("ws: channelReadAudience GetChannel failed, denying",
"channel_id", channelID, "err", err)
return []int64{}
}
// Fail closed on a missing row too (OC-0090): GetChannel returns
// (nil, nil) for a deleted channel, and falling through would hand a
// channel with no override rows left to the role scan below — which
// resolves to every connected user with base READ_MESSAGES, leaking
// e.g. a closed group-DM's voice_leave server-wide. Callers that
// tear down voice union the room's participants and the leaver back
// in afterwards, so eviction/E2EE-teardown signals still arrive.
if ch == nil {
return []int64{}
}
if ch.Type == "dm" {
return h.channelReadAudienceDM(ctx, channelID, userIDs)
}
ref = channelRef(ch)
// CanViewChannel hides an archived channel from everyone, mirroring
// RefreshChannelVisibility and VisibleChannelIDs: without that, an
// admin edit to an archived channel (or a voice teardown inside one)
// would fan out to every connected user whose base role holds
// READ_MESSAGES, none of whom have the channel in their sidebar.
// ignoreArchived resolves the pre-archival audience instead — see
// channelReadAudienceIgnoringArchived.
if ignoreArchived {
ref.Archived = false
}
}
// Resolved per USER, not memoised per role: channel_user_overrides is the
// last layer of the resolution order, so two members of the same role can
// legitimately disagree about one channel and a per-role memo would hand
// one of them the other's verdict. The verdict is CanViewChannel over
// subjectFor (cached service or live checker); an unresolvable user is
// left out.
audience := make([]int64, 0, len(userIDs))
for _, uid := range userIDs {
sub, err := h.subjectFor(ctx, uid, channelID)
if err != nil {
continue
}
sub.Channel = ref
if permissions.CanViewChannel(sub) == nil {
audience = append(audience, uid)
}
}
return audience
}
// channelReadAudienceDM resolves the audience of a DM channel: the DM's
// participants, intersected with the connected userIDs. Split verbatim out of
// channelReadAudienceImpl; the reason a DM must not fall through to the role
// scan is on the call site.
func (h *Hub) channelReadAudienceDM(ctx context.Context, channelID int64, userIDs []int64) []int64 {
participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)
if err != nil {
slog.Error("ws: channelReadAudience GetDMParticipantIDs failed, denying",
"channel_id", channelID, "err", err)
return []int64{}
}
connected := make(map[int64]struct{}, len(userIDs))
for _, uid := range userIDs {
connected[uid] = struct{}{}
}
audience := make([]int64, 0, len(participantIDs))
for _, uid := range participantIDs {
if _, ok := connected[uid]; ok {
audience = append(audience, uid)
}
}
return audience
}
// RefreshChannelVisibility re-evaluates which connected clients may see ch
// after a channel_overrides change and sends targeted channel_create /
// channel_delete messages so sidebars converge without a reconnect. Clients
// that lose visibility are also unsubscribed from the channel topic and have
// their focused channel cleared so live messages stop flowing.
//
// The sends deliberately bypass the sequenced broadcast/replay path: a
// replayed channel_delete would be filtered by the allowed-channel set
// computed at replay time, which after an override change is exactly the
// inverse of the intended audience. Clients tolerate seq-less messages.
func (h *Hub) RefreshChannelVisibility(ch *db.Channel) {
if ch == nil {
return
}
// Bump the watermark immediately, before the h.clients snapshot below and
// the (potentially slow — up to two DB round trips per connected client)
// fan-out loop that follows it. A reconnect handshake re-checks this
// watermark right before it registers (OC-0206); bumping only at the end,
// after the loop, left a window where that re-check could still observe
// the pre-change value even though this function's snapshot — taken next
// — will never include a client that registers mid-loop. Ratcheted
// upward only (see bumpVisibilityWatermark), so this is a no-op whenever
// a concurrent writer already pushed the watermark higher; the trailing
// bump below still runs and covers any change to h.seq made during the
// loop itself.
h.bumpVisibilityWatermark()
h.mu.RLock()
clients := make([]*Client, 0, len(h.clients))
for _, c := range h.clients {
clients = append(clients, c)
}
h.mu.RUnlock()
// Called via the admin HubBroadcaster interface, which carries no context;
// the targeted re-sync must complete regardless of the triggering request.
ctx := context.Background()
// Visibility is CanViewChannel — the single predicate shared with
// buildReady / REST ListVisibleChannels — resolved per user from their
// CURRENT role (c.user is a connect-time snapshot). With a
// PermissionService the subject comes from the per-user cache — safe
// because the admin handlers invalidate (InvalidateAll on override
// change, InvalidateUser on role change) before calling into the hub, so
// the lookups below repopulate from post-change data; the 30s TTL is only
// a backstop and the F6 gen-counter guard keeps a racing populate from
// caching stale rows. Without a service (bare test hubs) each client is
// resolved live. Fails closed: an unresolvable role loses visibility
// rather than keeping a stale grant.
//
// Deliberately NOT memoised per role: channel_user_overrides is the last
// layer of the resolution order, so two members of the same role can
// legitimately disagree about one channel — exactly the case a per-user
// override edit creates, and exactly the fan-out this function targets.
for _, c := range clients {
if c.user == nil {
continue
}
sub, err := h.subjectFor(ctx, c.user.ID, ch.ID)
if err != nil {
slog.Warn("hub: RefreshChannelVisibility could not resolve permissions, revoking",
"user_id", c.user.ID, "channel_id", ch.ID, "err", err)
}
sub.Channel = channelRef(ch)
visible := err == nil && permissions.CanViewChannel(sub) == nil
if refreshChannelVisibilityRaceHook != nil {
refreshChannelVisibilityRaceHook(c.user.ID)
}
// Re-resolve the live client immediately before acting: the permission
// lookups above (a PermissionService call, or two DB round trips in the
// bare-hub branch) give a reconnect room to replace this user's *Client
// in h.clients with a new connection under the same user ID. Acting on
// the stale snapshot pointer c would target a dead socket, and
// Unsubscribe would be a no-op — unsubscribeLocked's identity guard
// leaves a topic alone when the current holder differs from the client
// passed in — stranding the replacement with a subscription (or a
// missing one) exactly inverted from what this fan-out just decided.
// A nil result means the user disconnected entirely since the
// snapshot; nothing to act on.
live := h.GetClient(c.user.ID)
if live == nil {
continue
}
if visible {
// Idempotent add on the client; also refreshes channel metadata.
// Addressed per client so it can carry this recipient's own
// can_send verdict — the whole point of this fan-out is that a
// permission change just made those verdicts diverge.
live.sendMsg(buildChannelCreateFor(ch, h.refreshChannelVisibilityCanSend(ctx, ch, c.user.ID)))
continue
}
live.sendMsg(buildChannelDelete(ch.ID))
h.pubsub.Unsubscribe(live, ChannelTopic(ch.ID))
live.mu.Lock()
if live.channelID == ch.ID {
live.channelID = 0
}
live.mu.Unlock()
}
// Clients not connected right now missed the targeted sends above. Move
// the watermark so any resume from a seq at or before this point is
// forced onto the full-ready path instead of replay. Ratcheted upward
// only — see bumpVisibilityWatermark — so a concurrent writer that read
// an older seq cannot regress a watermark another writer already pushed
// higher.
h.bumpVisibilityWatermark()
}
// refreshChannelVisibilityCanSend is the can_send verdict the ready payload
// ships per channel (channelCanSend), recomputed for one live user from their
// CURRENT role: permissions.CanSendMessage over the subject subjectFor
// resolves in either the service or the bare-hub branch, failing closed on a
// lookup error (S-12).
//
// Without this, can_send is only ever computed at connect time, so a role
// edit or override edit leaves every connected client's composer stuck on
// its stale connect-time verdict until the socket is rebuilt.
func (h *Hub) refreshChannelVisibilityCanSend(ctx context.Context, ch *db.Channel, userID int64) bool {
sub, err := h.subjectFor(ctx, userID, ch.ID)
if err != nil {
return false
}
sub.Channel = channelRef(ch)
return permissions.CanSendMessage(sub) == nil
}
// RefreshAllChannelVisibility re-runs RefreshChannelVisibility for every
// non-DM channel. A role's permission mask is the base every channel's
// effective permission is computed from, so editing or deleting a role can
// change visibility of *any* channel at once — where a channel_overrides edit
// touches exactly one. DM channels are skipped: their access is participant-
// based and no role change can alter it.
//
// Called via the admin HubBroadcaster interface (no context), so the channel
// list is read against Background — the re-sync must complete regardless of the
// triggering request. The caller invalidates the permission cache first, as the
// channel-override handlers do, so the per-client lookups below repopulate from
// post-change data.
func (h *Hub) RefreshAllChannelVisibility() {
if h.db == nil {
return
}
ctx := context.Background()
channels, err := h.db.ListChannels(ctx)
if err != nil {
slog.Warn("hub: RefreshAllChannelVisibility could not list channels", "err", err)
return
}
for i := range channels {
if channels[i].Type == "dm" {
continue
}
h.RefreshChannelVisibility(&channels[i])
}
}
func (h *Hub) revokeUnreadableChannels(userID int64) {
// Ratcheted upward only (see bumpVisibilityWatermark), and evaluated at
// defer-RUN time — not the plain Store(Load(&h.seq)) this used to be,
// whose argument would have been evaluated at this defer STATEMENT,
// capturing entry-time seq and stomping any higher watermark stored by a
// concurrent writer during the per-topic DB loop below. Deferred because
// it must cover the early returns too: a user who is offline, or whose
// socket is closed below, converges via the full-ready path.
defer h.bumpVisibilityWatermark()
// Also bump immediately, before the h.clients lookup below and the
// per-topic DB loop (a GetChannel round trip per revoked topic) that
// follows it — see RefreshChannelVisibility's matching early bump and
// OC-0206. Ratcheted upward only, so this is a no-op whenever a
// concurrent writer already pushed the watermark higher; the deferred
// bump above still covers every return path, including the early ones.
h.bumpVisibilityWatermark()
if h.db == nil {
return
}
h.mu.RLock()
c, ok := h.clients[userID]
h.mu.RUnlock()
if !ok || c.user == nil {
return
}
// Called via the admin HubBroadcaster interface, which carries no context;
// the re-evaluation must complete regardless of the triggering request.
ctx := context.Background()
// c.user is a connect-time snapshot and the role just changed, so resolve
// the current user — and through it the current role — from the DB.
var allowed map[int64]bool
user, err := h.db.GetUserByID(ctx, userID)
if err == nil && user != nil {
// Same predicate as the ready payload and reconnect replay filtering.
allowed, err = h.computeAllowedChannels(ctx, h.db, user)
}
if err != nil || user == nil {
// Visibility unresolved. Keeping the old subscriptions would leak, and
// revoking them all would hollow out a sidebar the user may still be
// entitled to, so close the socket instead: the client reconnects and
// rebuilds from a ready payload computed with the new role. kickClient
// rather than DisconnectUser — the latter sends a BANNED error, which
// makes the client clear its credentials instead of reconnecting.
slog.Warn("hub: role change visibility unresolved, closing socket",
"user_id", userID, "err", err)
// Re-resolve before kicking: the lookups above are DB round trips a
// reconnect can overlap, and kicking the stale snapshot would close a
// dead socket while the replacement keeps its subscriptions.
if live := h.GetClient(userID); live != nil {
h.kickClient(live)
}
return
}
for _, topic := range h.pubsub.TopicsForClient(userID) {
chID := channelTopicID(topic)
if chID == 0 || allowed[chID] {
continue
}
// DM access is gated on dm_participants, which no role change can
// alter, while allowed sources DMs from dm_open_state — a DM the user
// has closed (or every DM, if the DM lookup inside
// computeAllowedChannels failed) is missing from allowed even though
// its subscription is still legitimate. Never revoke a DM topic here;
// on a lookup error close the socket rather than guess.
ch, chErr := h.db.GetChannel(ctx, chID)
if chErr != nil {
slog.Warn("hub: role change channel lookup failed, closing socket",
"user_id", userID, "channel_id", chID, "err", chErr)
if live := h.GetClient(userID); live != nil {
h.kickClient(live)
}
return
}
if ch != nil && ch.Type == "dm" {
continue
}
if revokeUnreadableChannelsPreActRaceHook != nil {
revokeUnreadableChannelsPreActRaceHook(userID)
}
// Re-resolve the live client immediately before acting: the DB round
// trips above (and computeAllowedChannels before the loop) give a
// reconnect room to replace this user's *Client in h.clients. Acting
// on the snapshot c would target the dead socket, and Unsubscribe
// would no-op on unsubscribeLocked's identity guard — stranding the
// replacement with the revoked topic (audit-2026-08-19 F-2; mirrors
// RefreshChannelVisibility's live re-resolve). A nil result means the
// user disconnected entirely; nothing left to revoke.
live := h.GetClient(userID)
if live == nil {
return
}
live.sendMsg(buildChannelDelete(chID))
h.pubsub.Unsubscribe(live, topic)
live.mu.Lock()
if live.channelID == chID {
live.channelID = 0
}
live.mu.Unlock()
}
}
// computeAllowedChannels returns the set of channel IDs a user may access,
// including both server channels (filtered by ReadMessages permission) and
// the user's open DM channels. The server-channel set comes from the single
// permissions.Checker predicate shared with buildReady and REST
// ListVisibleChannels, so replay-buffer filtering can never drift from the
// ready payload's visible channels.
func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user *db.User) (map[int64]bool, error) {
channels, err := database.ListChannels(ctx)
if err != nil {
return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err)
}
role, err := database.GetRoleByID(ctx, user.RoleID)
if err != nil {
return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err)
}
// Nil role = zero access (fail closed). Admins skip the override fetch.
allowed := make(map[int64]bool)
if role != nil {
var overrides map[int64]db.ChannelOverride
if !permissions.HasAdmin(role.Permissions) {
overrides, err = database.GetChannelOverridesFor(ctx, role.ID, user.ID)
if err != nil {
return nil, fmt.Errorf("computeAllowedChannels GetChannelOverridesFor: %w", err)
}
}
allowed = h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides))
}
// Include the user's open DM channels. Only the ID set matters here, so
// use the PK-covered dm_open_state lookup instead of the full DM query.
// Fatal like the three sibling lookups above: a silently DM-stripped
// replay advances the client's lastSeq past DM events it never received —
// a permanent hole. The caller's error path falls back to full ready.
dmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID)
if dmErr != nil {
return nil, fmt.Errorf("computeAllowedChannels GetUserDMChannelIDs: %w", dmErr)
}
for _, id := range dmIDs {
allowed[id] = true
}
return allowed, nil
}
// bumpVisibilityWatermark ratchets visibilityChangeSeq up to the current seq,
// never down. All three writers (RefreshChannelVisibility,
// revokeUnreadableChannels, DMChannelOpenEvent in emit.go) must go through
// this instead of a plain Store: a plain Store(Load(&h.seq)) lets a writer
// that read an older h.seq — e.g. one that spent time in a per-topic DB loop
// — finish and overwrite a concurrently stored higher watermark with its
// stale value, silently regressing the forced-full-resync boundary mustFullResync
// depends on being monotonic. Mirrors SeedSeq's CAS-max pattern.
func (h *Hub) bumpVisibilityWatermark() {
for {
cur := h.visibilityChangeSeq.Load()
next := atomic.LoadUint64(&h.seq)
if next <= cur {
return
}
if h.visibilityChangeSeq.CompareAndSwap(cur, next) {
return
}
}
}
// MarkVisibilityChanged bumps the visibility watermark. It is the exported
// entry point REST handlers (api.markDMVisibilityChanged, reached via a
// dmVisibilityMarker type assertion) use to force the same full-resync
// guarantee for an unsequenced, targeted DM event that the WS-side emitter of
// the same event (emit.go DMChannelOpenEvent) already gets via
// bumpVisibilityWatermark directly.
func (h *Hub) MarkVisibilityChanged() {
h.bumpVisibilityWatermark()
}
+1 -1
View File
@@ -74,7 +74,7 @@ func TestTheLoadTest(t *testing.T) {
limiter := auth.NewRateLimiter()
svc := service.New(database, limiter)
hub := newTestHubDeps(t, database, limiter, svc)
hub := ws.NewHub(database, limiter, svc)
runDone := make(chan struct{})
go func() {
@@ -30,6 +30,7 @@ import (
"testing"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
)
@@ -54,7 +55,8 @@ func TestVoiceJoin_GetChannelVoiceStatesError_RollsBackAndNotifiesClient(t *test
t.Fatalf("NewLiveKitClient: %v", err)
}
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: lk})
h := NewHub(database, auth.NewRateLimiter(), nil)
h.SetLiveKit(lk)
send := make(chan []byte, 8)
c := NewTestClient(h, uid, send)
@@ -25,7 +25,7 @@ func TestHandleMessageSessionRecheck_TransientDBErrorDoesNotKick(t *testing.T) {
t.Fatalf("CreateSession: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
c := NewTestClient(h, uid, make(chan []byte, 8))
c.tokenHash = tokenHash
// Put the client one message away from the periodic recheck boundary, so
@@ -26,6 +26,7 @@ import (
"testing"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
)
@@ -48,7 +49,8 @@ func TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic(t *testing.
t.Fatalf("NewLiveKitClient: %v", err)
}
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: lk})
h := NewHub(database, auth.NewRateLimiter(), nil)
h.SetLiveKit(lk)
send := make(chan []byte, 8)
c := NewTestClient(h, uid, send)
@@ -68,7 +68,7 @@ func TestReconnect_AuthOKReflectsSettledStatus_NotDisconnectTimeStatus(t *testin
t.Fatalf("CreateSession: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
+3 -3
View File
@@ -50,7 +50,7 @@ func TestFreshConnectCleanStaleVoice_ClearsOldClientVoiceState(t *testing.T) {
t.Fatalf("GetVoiceState: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
// The still-registered OLD client from the previous session.
old := NewTestClient(h, uid, make(chan []byte, 32))
@@ -113,7 +113,7 @@ func TestUpgradeAndAuth_RoleLookupFailure_FailsClosed(t *testing.T) {
t.Fatalf("CreateSession: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
type upgradeResult struct {
c *Client
@@ -182,7 +182,7 @@ func TestRefreshUserSnapshot_FailsClosedForBannedUser(t *testing.T) {
t.Fatalf("GetUserByID: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
c := newClient(hub, nil, user, "", 0, ctx)
// The ban commits AFTER authenticateConn passed (c.user is still the
@@ -23,6 +23,7 @@ import (
"slices"
"testing"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
@@ -50,7 +51,8 @@ func TestRollbackVoiceJoin_BroadcastReachesLeaverWithoutReadAccess(t *testing.T)
t.Fatalf("NewLiveKitClient: %v", err)
}
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: lk})
h := NewHub(database, auth.NewRateLimiter(), nil)
h.SetLiveKit(lk)
send := make(chan []byte, 8)
c := NewTestClient(h, uid, send)
+1 -1
View File
@@ -28,7 +28,7 @@ import (
func TestRegisterNow_ResyncsPeerE2EEKeyOnResume(t *testing.T) {
database := newTeardownTestDB(t)
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
const (
chanID = int64(500)
@@ -43,7 +43,7 @@ func TestRefreshUserSnapshot_FailsClosedWhenNewRoleLookupFails(t *testing.T) {
t.Fatalf("GetUserByID: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
c := newClient(hub, nil, user, "", 0, ctx)
c.roleName = "member"
@@ -30,7 +30,7 @@ import (
func TestRegisterNow_ReannouncesOwnKeyOnResume(t *testing.T) {
database := newTeardownTestDB(t)
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
const (
chanID = int64(500)
+2 -2
View File
@@ -43,7 +43,7 @@ func TestPermCache_RoleChangeInvalidationIsImmediate(t *testing.T) {
database := openHandlerDB(t)
limiter := auth.NewRateLimiter()
svc := service.New(database, limiter)
hub := newTestHubDeps(t, database, limiter, svc)
hub := ws.NewHub(database, limiter, svc)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
@@ -103,7 +103,7 @@ func TestPermCache_SecondCheckServedFromCache(t *testing.T) {
limiter := auth.NewRateLimiter()
store := &countingStore{Store: database}
svc := service.New(store, limiter)
hub := newTestHubDeps(t, database, limiter, svc)
hub := ws.NewHub(database, limiter, svc)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
+2 -2
View File
@@ -92,7 +92,7 @@ func TestRefreshChannelVisibilityCanSend_Parity(t *testing.T) {
t.Fatalf("retype: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
permSvc := service.NewPermissionService(database, h.permChecker)
for _, chID := range []int64{textID, newsID} {
@@ -143,7 +143,7 @@ func newViewParityFixture(t *testing.T) *viewParityFixture {
if _, err := database.ExecContext(ctx, `UPDATE channels SET archived = 1 WHERE id = ?`, oldID); err != nil {
t.Fatalf("archive: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
user, err := database.GetUserByID(ctx, uid)
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
+2 -4
View File
@@ -357,6 +357,7 @@ func newEpochRig(t *testing.T, journey string) *epochRig {
}
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter, service.New(database, limiter))
// A LiveKit client so voice_join clears the "voice not configured" guard.
// The join token is minted locally; no LiveKit process is contacted.
@@ -368,10 +369,7 @@ func newEpochRig(t *testing.T, journey string) *epochRig {
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
hub := newTestHubWith(t, ws.HubOptions{
DB: database, Limiter: limiter,
Services: service.New(database, limiter), LiveKit: lk,
})
hub.SetLiveKit(lk)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
+2 -2
View File
@@ -54,7 +54,7 @@ func TestReconnect_AuthFrameActiveChannelRestoresSubscription(t *testing.T) {
t.Fatalf("CreateSession: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
@@ -180,7 +180,7 @@ func TestReconnect_AuthFrameActiveChannelIsReadGated(t *testing.T) {
t.Fatalf("CreateSession: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
+3 -3
View File
@@ -87,7 +87,7 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) {
}
// Build hub, attach the DB event store as the cold-tier read path.
hub := newTestHubDeps(t, database, limiter, nil)
hub := ws.NewHub(database, limiter, nil)
hub.SetEventStore(eventStore)
go hub.Run()
defer hub.Stop()
@@ -208,7 +208,7 @@ func TestReconnect_ColdTierAtRowLimit_ForcesFullReady(t *testing.T) {
t.Fatalf("PersistEvents: persisted %d/%d, err=%v", n, len(events), err)
}
hub := newTestHubDeps(t, database, limiter, nil)
hub := ws.NewHub(database, limiter, nil)
hub.SetEventStore(eventStore)
go hub.Run()
defer hub.Stop()
@@ -304,7 +304,7 @@ func TestReconnect_ColdTierMergesRingBufferTail(t *testing.T) {
}
}
hub := newTestHubDeps(t, database, limiter, nil)
hub := ws.NewHub(database, limiter, nil)
hub.SetEventStore(eventStore)
go hub.Run()
defer hub.Stop()
@@ -52,7 +52,7 @@ func TestReconnect_FullReadyFallbackDoesNotLeakRevokedChannelSubscription(t *tes
t.Fatalf("CreateSession: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
+1 -1
View File
@@ -75,7 +75,7 @@ func TestReconnect_InteriorGap_ForcesFullReady(t *testing.T) {
}
}
hub := newTestHubDeps(t, database, limiter, nil)
hub := ws.NewHub(database, limiter, nil)
hub.SetEventStore(eventStore)
go hub.Run()
defer hub.Stop()
+1 -1
View File
@@ -64,7 +64,7 @@ func TestReconnect_PrunedPrefix_ForcesFullReady(t *testing.T) {
}
}
hub := newTestHubDeps(t, database, limiter, nil)
hub := ws.NewHub(database, limiter, nil)
hub.SetEventStore(eventStore)
go hub.Run()
defer hub.Stop()
+1 -1
View File
@@ -46,7 +46,7 @@ func TestHandleReconnect_VisibilityChangeDuringHandshake_ForcesFullReady(t *test
t.Fatalf("GetUserByID: %v", err)
}
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
h := NewHub(database, auth.NewRateLimiter(), nil)
// Precondition: the channel starts out READ-visible to this user.
allowedBefore, err := h.computeAllowedChannels(ctx, database, user)
@@ -25,6 +25,8 @@ import (
"context"
"fmt"
"testing"
"github.com/J3vb/OwnCord/Server/auth"
)
func TestLiveVoiceEventsSince_ColdTierCapHit_DegradesToNil(t *testing.T) {
@@ -47,8 +49,9 @@ func TestLiveVoiceEventsSince_ColdTierCapHit_DegradesToNil(t *testing.T) {
}
}
hub := newTestHubWith(t, HubOptions{DB: database, ReplayColdLimit: coldCap})
hub := NewHub(database, auth.NewRateLimiter(), nil)
hub.SetEventStore(database)
hub.ConfigureReplay(0, coldCap) // must run before Run(); this test never calls Run()
// Ring buffer is untouched (nothing pushed), so EventsSinceFiltered
// returns nil and liveVoiceEventsSince must fall through to the cold tier.
@@ -82,8 +85,9 @@ func TestLiveVoiceEventsSince_ColdTierExactCap_ReturnsCompleteWindow(t *testing.
}
}
hub := newTestHubWith(t, HubOptions{DB: database, ReplayColdLimit: coldCap})
hub := NewHub(database, auth.NewRateLimiter(), nil)
hub.SetEventStore(database)
hub.ConfigureReplay(0, coldCap) // must run before Run(); this test never calls Run()
got := hub.liveVoiceEventsSince(ctx, 0, chID)
if len(got) != len(types) {
+1 -1
View File
@@ -72,7 +72,7 @@ func TestReconnect_ReplaysOwnVoiceRoomOutsideReadableChannels(t *testing.T) {
t.Fatalf("CreateSession: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
-496
View File
@@ -1,496 +0,0 @@
package ws
import (
"context"
"log/slog"
"sync/atomic"
"github.com/coder/websocket"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/telemetry"
)
const (
// maxColdReplay caps how many persisted events a single cold-tier reconnect
// replay may return. A gap that reaches the cap cannot be replayed correctly
// and falls back to a full ready — see handleReconnect.
maxColdReplay = 5000
)
// handleReconnect attempts to resume a client via replay. Its two return
// values are independent signals for ServeWS:
// - handled reports whether this function owns the outcome of the
// connection attempt. false means "replay isn't possible, fall through
// to handleFreshConnect for a full ready."
// - startPumps reports whether ServeWS should start readPump/writePump.
// It is only meaningful when handled is true, and is false on the
// handshake-write-failure paths below: those paths already ran the full
// unregisterFailedHandshake teardown and closed conn themselves, so no
// pump may start — readPump's defer would find the client already gone
// (unregisterNow reporting replaced=false) and run that same teardown a
// second time (OC-0051): a duplicate MarkUserDisconnected, a duplicate
// offline presence broadcast, and a duplicate hub seq for it.
func (h *Hub) handleReconnect(
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64,
) (handled, startPumps bool) {
allowedChannelIDs, ok := h.reconnectPrecheck(ctx, database, c, lastSeq)
if !ok {
return false, false
}
// Voice membership needs only CONNECT_VOICE, not READ_MESSAGES
// (voice_join.go), so a live participant resuming can have their own room
// excluded from allowedChannelIDs entirely — most commonly a DM voice call
// after the DM was closed (computeAllowedChannels sources DM IDs from
// dm_open_state). Capture it before registerNow performs the same
// lookup/transfer, so replay can be supplemented below with the room's own
// voice_state/voice_leave even though the room is outside the READ-gated
// allowed set. It is never added to allowedChannelIDs itself — that map
// also gates the ChannelTopic subscription in registerNow and would leak
// the channel's chat to a user who cannot read it.
var liveVoiceChID int64
if old := h.GetClient(c.userID); old != nil {
liveVoiceChID = old.getVoiceChID()
}
events, replaySource, persistedTail, maxPersistedSeq := h.reconnectSelectReplay(ctx, c, lastSeq, allowedChannelIDs)
if events == nil {
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return false, false
}
// Register BEFORE writing replay data so broadcasts that arrive during
// the write window are queued in the client's send buffer instead of
// being lost (BUG-123). writePump hasn't started yet, so queued messages
// will be drained once the pumps begin.
//
// The replay set built above can go stale between being read and c
// becoming reachable: deliverBroadcast (the hub's own Run goroutine)
// allocates a seq, pushes it to the ring buffer, and publishes to current
// subscribers — all under h.seqMu — concurrently with this handshake
// goroutine. registerNow is what subscribes this connection, so a
// broadcast landing in the gap between the snapshot above and
// registration reaches nobody, and the client's max(seq)-only tracking
// means it can never be requested again once a later frame arrives.
// Close the window by re-reading the ring-buffer-derived portion of
// `events` and calling registerNow inside the SAME h.seqMu critical
// section deliverBroadcast uses, so no seq can be allocated in between
// (reconnectRegister below).
// Restore the client's channel subscription BEFORE registration.
//
// registerNow copies the channel subscription from the OLD client entry,
// but on a resume where the server already observed the previous socket
// close there is no old entry to copy from — so without this the resumed
// connection holds no ChannelTopic subscription until its post-auth_ok
// channel_focus round trip completes. Everything broadcast to that channel
// in the window (auth_ok write + up to maxColdReplay replay frames + pump
// startup + one RTT) is delivered to nobody on this socket, and the client
// can never ask for it back because it only ever reports max(seq).
//
// Set outside the h.seqMu section below so this does not introduce a
// seqMu -> c.mu lock-order edge that nothing else in the hub has.
//
// c.authChannelID is attacker-controlled, so it is honoured only when the
// freshly computed read-permission set contains it. Fail closed: an
// unknown or now-unreadable id leaves channelID at 0, which is exactly the
// pre-existing behaviour rather than a new denial.
if c.authChannelID != 0 {
if allowedChannelIDs[c.authChannelID] {
c.mu.Lock()
c.channelID = c.authChannelID
c.mu.Unlock()
} else {
slog.Debug("ws handleReconnect: ignoring unreadable active_channel_id from auth frame",
"user_id", c.userID, "channel_id", c.authChannelID)
}
}
events, ok = h.reconnectRegister(ctx, c, lastSeq, allowedChannelIDs, replaySource, persistedTail, maxPersistedSeq)
if !ok {
return false, false
}
switch replaySource {
case "buffer":
h.reconnectTierBuf.Add(1)
case "db":
h.reconnectTierDB.Add(1)
}
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", replaySource))
// Best-effort supplement: the user's own live voice room may sit outside
// allowedChannelIDs (see the capture of liveVoiceChID above), so its
// voice_state/voice_leave would otherwise never reach this replay at all.
// Tries the ring buffer first, then the cold-tier store; a miss on both
// just leaves this one supplement as a no-op, not a regression versus the
// pre-fix behaviour.
if liveVoiceChID != 0 && !allowedChannelIDs[liveVoiceChID] {
events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...)
}
// Settle the session's status BEFORE the auth_ok write below, mirroring
// handleFreshConnect's ordering: reconnectWriteReplay reads c.user.Status
// to build auth_ok, so if this ran after that write the resumed client
// would be told its disconnect-time status (routinely "offline", since
// MarkUserDisconnected just rewrote it) instead of the status it is about
// to come online as and broadcast (OC-0222). Skips member_join — the user
// was already known.
applyConnectStatus(ctx, database, c)
if !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) {
// startPumps=false: the teardown inside reconnectWriteReplay already ran
// in full. Starting readPump on this closed conn would hit an immediate
// Read error and its defer would run the identical teardown a second
// time (OC-0051).
return true, false
}
h.announceConnectPresence(c)
return true, true
}
// reconnectPrecheck runs handleReconnect's two entry guards and, when replay is
// still on the table, returns the read-permission set replay is filtered by.
// ok=false means the caller must fall through to a full ready.
func (h *Hub) reconnectPrecheck(
ctx context.Context, database *db.DB, c *Client, lastSeq uint64,
) (map[int64]bool, bool) {
// Channel-visibility changes are delivered as targeted, unsequenced
// messages, so replay cannot bring a client that missed one back into a
// coherent state — force the full-ready path instead.
if h.mustFullResync(lastSeq) {
slog.Info("ws replay skipped (visibility changed since last_seq), sending full ready",
"user_id", c.userID, "last_seq", lastSeq)
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return nil, false
}
// c.user is the auth-time snapshot; a role reassignment landing between
// authenticateConn and here would otherwise be resolved from the OLD
// RoleID for the rest of this socket's life — revokeUnreadableChannels
// cannot reach a mid-handshake socket (it early-returns when the user is
// not yet in h.clients), and nothing revalidates handshake-time
// subscriptions afterwards (audit-2026-08-19 F-2). Re-read the row so
// the permission set below is computed from the CURRENT role.
if err := h.refreshUserSnapshot(ctx, database, c); err != nil {
slog.Warn("ws handleReconnect: user re-read failed, falling back to full ready",
"user_id", c.userID, "err", err)
return nil, false
}
// Compute the set of channel IDs the reconnecting user can access so that
// channel-scoped replay events are filtered by current permissions (M3).
allowedChannelIDs, err := h.computeAllowedChannels(ctx, database, c.user)
if err != nil {
slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready",
"user_id", c.userID, "err", err)
return nil, false
}
return allowedChannelIDs, true
}
// reconnectSelectReplay picks the tier that serves this resume — the ring
// buffer when it still covers lastSeq, otherwise the cold-tier EventStore — and
// returns the events found, the tier name, and (cold tier only) the persisted
// rows plus their highest seq, which reconnectRegister needs for its re-read.
// A nil events return means neither tier can replay and the caller must fall
// through to a full ready.
func (h *Hub) reconnectSelectReplay(
ctx context.Context, c *Client, lastSeq uint64, allowedChannelIDs map[int64]bool,
) ([][]byte, string, [][]byte, uint64) {
var (
events [][]byte
replaySource = "buffer"
persistedTail [][]byte // cold-tier rows only; re-merged with a fresh buffer tail below
maxPersistedSeq uint64
)
if buf := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs); buf != nil {
events = buf
return events, replaySource, persistedTail, maxPersistedSeq
}
// Phase B Step 7 — try cold-tier replay from the EventStore before
// giving up and forcing a full ready re-sync.
if esp := h.eventStore.Load(); esp != nil {
es := *esp
channelIDs := make([]int64, 0, len(allowedChannelIDs))
for cid := range allowedChannelIDs {
channelIDs = append(channelIDs, cid)
}
coldCap := h.maxColdReplayLimit()
persisted, dbErr := es.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, coldCap) //nolint:gosec // lastSeq is a sequence counter bounded well below MaxInt64
switch {
case dbErr != nil:
slog.Warn("ws handleReconnect: cold-tier replay query failed",
"user_id", c.userID, "err", dbErr)
case len(persisted) >= coldCap:
// The query is "ORDER BY seq ASC LIMIT maxColdReplay", so a full
// result means the gap exceeds the cap and the NEWEST events were
// dropped. Replaying it would look like a complete resume to the
// client — it tracks only max(seq) and cannot detect the hole —
// silently losing state events that REST history never repairs.
// Leave events nil so the fall-through forces a full ready.
slog.Warn("ws handleReconnect: cold-tier replay hit the row cap, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq, "cap", coldCap)
case len(persisted) > 0:
// Retention pruning (PruneEventsOlderThan) deletes purely by
// created_at with no seq-floor coordination, so this
// channel-filtered result can be a surviving suffix left behind
// after the events between lastSeq and persisted[0] were
// pruned. Accepting it as-is would present a hole as a complete
// resume, since the client tracks only max(seq). Probe the
// store's oldest surviving seq UNFILTERED before trusting it —
// a channel-filtered contiguity check on persisted itself can't
// work, since a sparse per-channel result is legitimately
// non-contiguous.
oldest, oldestErr := es.GetEventsSince(ctx, 0, 1)
switch {
case oldestErr != nil:
slog.Warn("ws handleReconnect: cold-tier oldest-seq probe failed, forcing full ready",
"user_id", c.userID, "err", oldestErr)
case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: //nolint:gosec // seq is a counter bounded well below MaxInt64
var oldestSeq int64
if len(oldest) > 0 {
oldestSeq = oldest[0].Seq
}
slog.Warn("ws handleReconnect: retention pruning left a gap before last_seq, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq, "oldest_seq", oldestSeq)
default:
persistedTail, maxPersistedSeq = h.reconnectVetColdTail(ctx, c, es, lastSeq, persisted, allowedChannelIDs)
if persistedTail != nil {
events = persistedTail
replaySource = "db"
}
}
}
}
return events, replaySource, persistedTail, maxPersistedSeq
}
// reconnectVetColdTail turns a cold-tier result into a replayable tail, or
// returns nil when it cannot be trusted: the range it covers must have no
// interior gap, and the ring buffer must cover everything newer than its last
// row. The returned seq is the highest one in persisted.
func (h *Hub) reconnectVetColdTail(
ctx context.Context, c *Client, es EventStore, lastSeq uint64,
persisted []db.PersistedEvent, allowedChannelIDs map[int64]bool,
) ([][]byte, uint64) {
persistedTail := make([][]byte, 0, len(persisted))
for _, p := range persisted {
persistedTail = append(persistedTail, p.Payload)
}
maxPersistedSeq := uint64(persisted[len(persisted)-1].Seq) //nolint:gosec // seq is a counter bounded well below MaxInt64
// persisted is channel-filtered, so a hole in a channel
// outside allowedChannelIDs would slip past a contiguity
// check on persisted itself — and EventPersister can lose a
// row outright (a full queue drops silently in Enqueue, a
// per-row insert failure inside a batch flush is logged but
// never surfaced here; see event_persister.go). Count the
// UNFILTERED range (lastSeq, maxPersistedSeq] and require
// every seq in it to be present. seq is the events table's
// primary key, so the count can only come up short, never
// over.
expectedCount := maxPersistedSeq - lastSeq
switch gapCount, gapErr := es.CountEventsInRange(ctx, int64(lastSeq), int64(maxPersistedSeq)); { //nolint:gosec // bounded well below MaxInt64
case gapErr != nil:
slog.Warn("ws handleReconnect: cold-tier contiguity probe failed, forcing full ready",
"user_id", c.userID, "err", gapErr)
persistedTail = nil
case uint64(gapCount) != expectedCount: //nolint:gosec // bounded well below MaxInt64
slog.Warn("ws handleReconnect: cold-tier replay has an interior gap, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq, "max_persisted_seq", maxPersistedSeq,
"expected", expectedCount, "found", gapCount)
persistedTail = nil
}
if persistedTail != nil {
// The EventPersister flushes asynchronously, so cold rows can
// lag the live seq: events broadcast after the last flush sit
// only in the ring buffer. Confirm the buffer can cover
// everything above the newest persisted row — the
// authoritative re-read happens atomically with registerNow
// below, but a hole here must still force a full ready
// rather than a replay with a silent gap at its end.
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
case tail != nil:
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
// Post-restart empty buffer with the hub seq seeded from
// the store max: nothing was broadcast after the last
// persisted row, so the cold rows alone are complete.
default:
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail, forcing full ready",
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
persistedTail = nil
}
}
return persistedTail, maxPersistedSeq
}
// reconnectRegister re-reads the ring-buffer-derived portion of the replay and
// registers c inside the SAME h.seqMu critical section deliverBroadcast uses,
// so no seq can be allocated in between (see the comment in handleReconnect).
// It returns the events to actually send; ok=false means one of the re-checks
// tripped and the caller must fall through to a full ready.
func (h *Hub) reconnectRegister(
ctx context.Context, c *Client, lastSeq uint64, allowedChannelIDs map[int64]bool,
replaySource string, persistedTail [][]byte, maxPersistedSeq uint64,
) ([][]byte, bool) {
var events [][]byte
h.seqMu.Lock()
switch replaySource {
case "buffer":
fresh := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs)
if fresh == nil {
// The buffer window closed between the earlier check and this
// lock (an extreme write burst evicted lastSeq) — there is
// nothing left to fall back to for this attempt but a full ready.
h.seqMu.Unlock()
slog.Warn("ws handleReconnect: buffer window closed just before registration, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq)
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return nil, false
}
events = fresh
case "db":
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
case tail != nil:
events = append(append([][]byte{}, persistedTail...), tail...)
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
events = persistedTail
default:
h.seqMu.Unlock()
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail just before registration, forcing full ready",
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return nil, false
}
}
if handleReconnectPreRegisterRaceHook != nil {
handleReconnectPreRegisterRaceHook()
}
// Re-check the watermark one last time, right before registerNow makes
// this connection reachable. RefreshChannelVisibility and
// revokeUnreadableChannels both iterate h.clients to fan out a targeted,
// unsequenced channel_create/channel_delete — a snapshot this
// still-mid-handshake connection is absent from — and both only bump the
// watermark afterward. Without this re-check, a visibility change that
// lands anywhere between the entry check above and here is missed twice:
// the fan-out can't reach an unregistered client, and the entry check has
// already passed, so nothing else catches it before this resume commits
// to permissions computed before the change (OC-0206).
if h.mustFullResync(lastSeq) {
h.seqMu.Unlock()
slog.Warn("ws handleReconnect: visibility changed during handshake, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq)
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return nil, false
}
h.registerNow(c, allowedChannelIDs)
h.seqMu.Unlock()
return events, true
}
// reconnectWriteReplay writes the resume handshake: auth_ok followed by the
// replayed events. A false return means a write failed, in which case the full
// unregisterFailedHandshake teardown has already run and conn is closed, so the
// caller must not start any pump (OC-0051).
func (h *Hub) reconnectWriteReplay(
ctx context.Context, conn *websocket.Conn, c *Client, lastSeq uint64,
events [][]byte, replaySource string,
) bool {
// Replay succeeded — send auth_ok then missed events. The replay tier
// is included in the payload so the client can attribute reconnect
// behaviour without separate metric scraping.
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource)
if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {
slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err)
h.unregisterFailedHandshake(ctx, c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return false
}
for _, evt := range events {
if err := handshakeWrite(ctx, conn, evt); err != nil {
slog.Warn("ws: failed to send replay event", "user_id", c.userID, "err", err)
h.unregisterFailedHandshake(ctx, c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return false
}
}
slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource)
return true
}
// liveVoiceEventsSince returns voice_state/voice_leave events for chID at or
// after afterSeq, bypassing the READ-gated channel filter entirely. Voice
// membership needs only CONNECT_VOICE (voice_join.go), so a resuming
// participant's own room is not always in their READ-visible set — a stock
// example is a DM voice call after the DM was closed. Tries the ring buffer
// first (fresh, so it observes anything pushed concurrently with the caller),
// then falls back to the cold-tier store; returns nil, not an error, on a
// miss in both, since this is a best-effort supplement to the main replay.
func (h *Hub) liveVoiceEventsSince(ctx context.Context, afterSeq uint64, chID int64) [][]byte {
if chID == 0 {
return nil
}
only := map[int64]bool{chID: true}
var raw [][]byte
if buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {
raw = buf
} else if esp := h.eventStore.Load(); esp != nil {
es := *esp
coldCap := h.maxColdReplayLimit()
// Fetch one row past the cap so truncation is decided by the presence
// of that extra row, not by len == cap: a complete window of exactly
// coldCap rows is not truncated and must replay in full (Codex review
// on #1436). A result of at most coldCap rows is therefore complete.
persisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, coldCap+1) //nolint:gosec // afterSeq is a sequence counter bounded well below MaxInt64
if err != nil {
return nil
}
if len(persisted) > coldCap {
// Same failure mode reconnectSelectReplay guards against above:
// the query is "ORDER BY seq ASC LIMIT n", so a result past the
// cap means the range exceeds it and any cap-sized window would
// have silently dropped the NEWEST rows — for a voice room, quite
// possibly the peer's voice_leave. Replaying a truncated window
// would install a join whose matching leave was discarded, which
// is worse than the documented best-effort miss this function
// already returns on a plain lookup failure. A full ready isn't
// available here (registerNow already ran before this supplement
// runs), so nil is the correct degradation.
slog.Warn("ws liveVoiceEventsSince: cold-tier supplement exceeds the row cap, skipping truncated window",
"chID", chID, "after_seq", afterSeq, "cap", coldCap)
return nil
}
raw = make([][]byte, 0, len(persisted))
for _, p := range persisted {
raw = append(raw, p.Payload)
}
}
if len(raw) == 0 {
return nil
}
filtered := make([][]byte, 0, len(raw))
for _, evt := range raw {
switch extractEventType(evt) {
case MsgTypeVoiceState, MsgTypeVoiceLeaveBC:
filtered = append(filtered, evt)
}
}
return filtered
}
// maxColdReplayLimit returns the effective persisted-replay cap. The budget
// arrives via HubOptions (B3-4): the dispatch loop reads replayBuf unlocked,
// so the ring is sized exactly once, at construction.
func (h *Hub) maxColdReplayLimit() int {
if h.coldReplayLimit > 0 {
return h.coldReplayLimit
}
return maxColdReplay
}
+4 -4
View File
@@ -154,7 +154,7 @@ func TestRefreshUserSnapshot_PicksUpRoleReassignment(t *testing.T) {
t.Fatalf("GetUserByID: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
c := newClient(hub, nil, user, "", 0, ctx)
c.roleName = "harvest-voice"
@@ -191,7 +191,7 @@ func TestFreshConnectFallback_RoleReassignMidReconnect_ResolvesFreshRole(t *test
t.Fatalf("CreateSession: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
@@ -277,7 +277,7 @@ func TestFreshConnectFallback_RoleReassignPreRegister_PostRegisterVerifyRevokes(
t.Fatalf("CreateSession: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
@@ -346,7 +346,7 @@ func TestRevokeUnreadableChannels_ActsOnReplacementClient(t *testing.T) {
t.Fatalf("GetUserByID: %v", err)
}
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
hub := NewHub(database, auth.NewRateLimiter(), nil)
c1 := newClient(hub, nil, user, "", 0, ctx)
c2 := newClient(hub, nil, user, "", 0, ctx)
+807
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/coder/websocket"
@@ -13,6 +14,8 @@ import (
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/telemetry"
)
const (
@@ -23,8 +26,31 @@ const (
// wsReadLimitBytes is the maximum size of a single inbound WebSocket
// message. Must match the client-side upload cap.
wsReadLimitBytes = config.MaxMessageBytes
// maxColdReplay caps how many persisted events a single cold-tier reconnect
// replay may return. A gap that reaches the cap cannot be replayed correctly
// and falls back to a full ready — see handleReconnect.
maxColdReplay = 5000
)
// handshakeWrite writes one handshake-phase message (auth_ok, ready, or a
// replay event) under writeTimeout, instead of the bare ctx every caller here
// otherwise has on hand.
//
// Every handshake write runs against ctx = r.Context() from ServeWS.
// websocket.Accept hijacks the connection, which stops net/http's own
// mechanism for cancelling that context on client disconnect, so without this
// wrapper ctx is never cancelled while the handler is blocked inside
// conn.Write — a peer that stops reading (or whose receive window closes)
// pins the write, the handler goroutine, and the socket forever (OC-0152).
// writePumpWrite (serve_pumps.go) already bounds its writes the same way;
// this brings the handshake writes in serve.go up to the same guarantee.
func handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error {
wCtx, cancel := context.WithTimeout(ctx, writeTimeout)
defer cancel()
return conn.Write(wCtx, websocket.MessageText, msg)
}
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
// then drives the client's read/write loops.
// Do not wrap with AuthMiddleware — WS does its own auth.
@@ -93,6 +119,45 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string, maxConns int) h
}
}
func (h *Hub) upgradeAndAuth(
conn *websocket.Conn, database *db.DB, r *http.Request,
) (*Client, uint64, error) {
user, tokenHash, hint, err := authenticateConn(r.Context(), conn, database)
if err != nil {
slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr)
_ = conn.Close(websocket.StatusPolicyViolation, "authentication failed")
return nil, 0, err
}
lastSeq := hint.LastSeq
c := newClient(h, conn, user, tokenHash, lastSeq, r.Context())
c.remoteAddr = r.RemoteAddr
// Untrusted until handleReconnect checks it against the allowed set.
c.authChannelID = hint.ChannelID
// Look up role name for protocol-compliant payloads and cache on client.
// Fail closed like the sibling lookup in handleFreshConnect (BUG-094):
// this value is authoritative on the wire — auth_ok reports it as the
// user's own role, member_join broadcasts it to every other client, and
// every chat_message carries it — so a lookup failure must not silently
// substitute "member" and pin the whole session to a fabricated role
// (OC-0269).
role, roleErr := database.GetRoleByID(r.Context(), user.RoleID)
if roleErr != nil || role == nil {
slog.Error("ws: role lookup failed during handshake, closing connection",
"user_id", user.ID, "role_id", user.RoleID, "err", roleErr)
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
return nil, 0, fmt.Errorf("upgradeAndAuth: role lookup failed for user %d: %w", user.ID, roleErr)
}
c.roleName = strings.ToLower(role.Name)
slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr)
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "ws_connect", "user", user.ID,
"WebSocket connected from "+r.RemoteAddr)
return c, lastSeq, nil
}
// handleReconnectPreRegisterRaceHook, when non-nil, runs once inside
// handleReconnect's h.seqMu critical section immediately before the
// mustFullResync re-check that guards registerNow. Test-only (nil in
@@ -110,6 +175,179 @@ var handleReconnectPreRegisterRaceHook func()
// deterministically, same pattern as handleReconnectPreRegisterRaceHook.
var freshConnectPreRegisterRaceHook func()
// handleReconnect attempts to resume a client via replay. Its two return
// values are independent signals for ServeWS:
// - handled reports whether this function owns the outcome of the
// connection attempt. false means "replay isn't possible, fall through
// to handleFreshConnect for a full ready."
// - startPumps reports whether ServeWS should start readPump/writePump.
// It is only meaningful when handled is true, and is false on the
// handshake-write-failure paths below: those paths already ran the full
// unregisterFailedHandshake teardown and closed conn themselves, so no
// pump may start — readPump's defer would find the client already gone
// (unregisterNow reporting replaced=false) and run that same teardown a
// second time (OC-0051): a duplicate MarkUserDisconnected, a duplicate
// offline presence broadcast, and a duplicate hub seq for it.
func (h *Hub) handleReconnect(
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64,
) (handled, startPumps bool) {
allowedChannelIDs, ok := h.reconnectPrecheck(ctx, database, c, lastSeq)
if !ok {
return false, false
}
// Voice membership needs only CONNECT_VOICE, not READ_MESSAGES
// (voice_join.go), so a live participant resuming can have their own room
// excluded from allowedChannelIDs entirely — most commonly a DM voice call
// after the DM was closed (computeAllowedChannels sources DM IDs from
// dm_open_state). Capture it before registerNow performs the same
// lookup/transfer, so replay can be supplemented below with the room's own
// voice_state/voice_leave even though the room is outside the READ-gated
// allowed set. It is never added to allowedChannelIDs itself — that map
// also gates the ChannelTopic subscription in registerNow and would leak
// the channel's chat to a user who cannot read it.
var liveVoiceChID int64
if old := h.GetClient(c.userID); old != nil {
liveVoiceChID = old.getVoiceChID()
}
events, replaySource, persistedTail, maxPersistedSeq := h.reconnectSelectReplay(ctx, c, lastSeq, allowedChannelIDs)
if events == nil {
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return false, false
}
// Register BEFORE writing replay data so broadcasts that arrive during
// the write window are queued in the client's send buffer instead of
// being lost (BUG-123). writePump hasn't started yet, so queued messages
// will be drained once the pumps begin.
//
// The replay set built above can go stale between being read and c
// becoming reachable: deliverBroadcast (the hub's own Run goroutine)
// allocates a seq, pushes it to the ring buffer, and publishes to current
// subscribers — all under h.seqMu — concurrently with this handshake
// goroutine. registerNow is what subscribes this connection, so a
// broadcast landing in the gap between the snapshot above and
// registration reaches nobody, and the client's max(seq)-only tracking
// means it can never be requested again once a later frame arrives.
// Close the window by re-reading the ring-buffer-derived portion of
// `events` and calling registerNow inside the SAME h.seqMu critical
// section deliverBroadcast uses, so no seq can be allocated in between
// (reconnectRegister below).
// Restore the client's channel subscription BEFORE registration.
//
// registerNow copies the channel subscription from the OLD client entry,
// but on a resume where the server already observed the previous socket
// close there is no old entry to copy from — so without this the resumed
// connection holds no ChannelTopic subscription until its post-auth_ok
// channel_focus round trip completes. Everything broadcast to that channel
// in the window (auth_ok write + up to maxColdReplay replay frames + pump
// startup + one RTT) is delivered to nobody on this socket, and the client
// can never ask for it back because it only ever reports max(seq).
//
// Set outside the h.seqMu section below so this does not introduce a
// seqMu -> c.mu lock-order edge that nothing else in the hub has.
//
// c.authChannelID is attacker-controlled, so it is honoured only when the
// freshly computed read-permission set contains it. Fail closed: an
// unknown or now-unreadable id leaves channelID at 0, which is exactly the
// pre-existing behaviour rather than a new denial.
if c.authChannelID != 0 {
if allowedChannelIDs[c.authChannelID] {
c.mu.Lock()
c.channelID = c.authChannelID
c.mu.Unlock()
} else {
slog.Debug("ws handleReconnect: ignoring unreadable active_channel_id from auth frame",
"user_id", c.userID, "channel_id", c.authChannelID)
}
}
events, ok = h.reconnectRegister(ctx, c, lastSeq, allowedChannelIDs, replaySource, persistedTail, maxPersistedSeq)
if !ok {
return false, false
}
switch replaySource {
case "buffer":
h.reconnectTierBuf.Add(1)
case "db":
h.reconnectTierDB.Add(1)
}
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", replaySource))
// Best-effort supplement: the user's own live voice room may sit outside
// allowedChannelIDs (see the capture of liveVoiceChID above), so its
// voice_state/voice_leave would otherwise never reach this replay at all.
// Tries the ring buffer first, then the cold-tier store; a miss on both
// just leaves this one supplement as a no-op, not a regression versus the
// pre-fix behaviour.
if liveVoiceChID != 0 && !allowedChannelIDs[liveVoiceChID] {
events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...)
}
// Settle the session's status BEFORE the auth_ok write below, mirroring
// handleFreshConnect's ordering: reconnectWriteReplay reads c.user.Status
// to build auth_ok, so if this ran after that write the resumed client
// would be told its disconnect-time status (routinely "offline", since
// MarkUserDisconnected just rewrote it) instead of the status it is about
// to come online as and broadcast (OC-0222). Skips member_join — the user
// was already known.
applyConnectStatus(ctx, database, c)
if !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) {
// startPumps=false: the teardown inside reconnectWriteReplay already ran
// in full. Starting readPump on this closed conn would hit an immediate
// Read error and its defer would run the identical teardown a second
// time (OC-0051).
return true, false
}
h.announceConnectPresence(c)
return true, true
}
// reconnectPrecheck runs handleReconnect's two entry guards and, when replay is
// still on the table, returns the read-permission set replay is filtered by.
// ok=false means the caller must fall through to a full ready.
func (h *Hub) reconnectPrecheck(
ctx context.Context, database *db.DB, c *Client, lastSeq uint64,
) (map[int64]bool, bool) {
// Channel-visibility changes are delivered as targeted, unsequenced
// messages, so replay cannot bring a client that missed one back into a
// coherent state — force the full-ready path instead.
if h.mustFullResync(lastSeq) {
slog.Info("ws replay skipped (visibility changed since last_seq), sending full ready",
"user_id", c.userID, "last_seq", lastSeq)
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return nil, false
}
// c.user is the auth-time snapshot; a role reassignment landing between
// authenticateConn and here would otherwise be resolved from the OLD
// RoleID for the rest of this socket's life — revokeUnreadableChannels
// cannot reach a mid-handshake socket (it early-returns when the user is
// not yet in h.clients), and nothing revalidates handshake-time
// subscriptions afterwards (audit-2026-08-19 F-2). Re-read the row so
// the permission set below is computed from the CURRENT role.
if err := h.refreshUserSnapshot(ctx, database, c); err != nil {
slog.Warn("ws handleReconnect: user re-read failed, falling back to full ready",
"user_id", c.userID, "err", err)
return nil, false
}
// Compute the set of channel IDs the reconnecting user can access so that
// channel-scoped replay events are filtered by current permissions (M3).
allowedChannelIDs, err := h.computeAllowedChannels(ctx, database, c.user)
if err != nil {
slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready",
"user_id", c.userID, "err", err)
return nil, false
}
return allowedChannelIDs, true
}
// refreshUserSnapshot replaces c.user (and, when the role changed, c.roleName)
// with a fresh read of the user row. Handshake paths call it before
// registerNow, while c is still invisible to every other goroutine, so the
@@ -148,6 +386,342 @@ func (h *Hub) refreshUserSnapshot(ctx context.Context, database *db.DB, c *Clien
return nil
}
// reconnectSelectReplay picks the tier that serves this resume — the ring
// buffer when it still covers lastSeq, otherwise the cold-tier EventStore — and
// returns the events found, the tier name, and (cold tier only) the persisted
// rows plus their highest seq, which reconnectRegister needs for its re-read.
// A nil events return means neither tier can replay and the caller must fall
// through to a full ready.
func (h *Hub) reconnectSelectReplay(
ctx context.Context, c *Client, lastSeq uint64, allowedChannelIDs map[int64]bool,
) ([][]byte, string, [][]byte, uint64) {
var (
events [][]byte
replaySource = "buffer"
persistedTail [][]byte // cold-tier rows only; re-merged with a fresh buffer tail below
maxPersistedSeq uint64
)
if buf := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs); buf != nil {
events = buf
return events, replaySource, persistedTail, maxPersistedSeq
}
// Phase B Step 7 — try cold-tier replay from the EventStore before
// giving up and forcing a full ready re-sync.
if esp := h.eventStore.Load(); esp != nil {
es := *esp
channelIDs := make([]int64, 0, len(allowedChannelIDs))
for cid := range allowedChannelIDs {
channelIDs = append(channelIDs, cid)
}
coldCap := h.maxColdReplayLimit()
persisted, dbErr := es.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, coldCap) //nolint:gosec // lastSeq is a sequence counter bounded well below MaxInt64
switch {
case dbErr != nil:
slog.Warn("ws handleReconnect: cold-tier replay query failed",
"user_id", c.userID, "err", dbErr)
case len(persisted) >= coldCap:
// The query is "ORDER BY seq ASC LIMIT maxColdReplay", so a full
// result means the gap exceeds the cap and the NEWEST events were
// dropped. Replaying it would look like a complete resume to the
// client — it tracks only max(seq) and cannot detect the hole —
// silently losing state events that REST history never repairs.
// Leave events nil so the fall-through forces a full ready.
slog.Warn("ws handleReconnect: cold-tier replay hit the row cap, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq, "cap", coldCap)
case len(persisted) > 0:
// Retention pruning (PruneEventsOlderThan) deletes purely by
// created_at with no seq-floor coordination, so this
// channel-filtered result can be a surviving suffix left behind
// after the events between lastSeq and persisted[0] were
// pruned. Accepting it as-is would present a hole as a complete
// resume, since the client tracks only max(seq). Probe the
// store's oldest surviving seq UNFILTERED before trusting it —
// a channel-filtered contiguity check on persisted itself can't
// work, since a sparse per-channel result is legitimately
// non-contiguous.
oldest, oldestErr := es.GetEventsSince(ctx, 0, 1)
switch {
case oldestErr != nil:
slog.Warn("ws handleReconnect: cold-tier oldest-seq probe failed, forcing full ready",
"user_id", c.userID, "err", oldestErr)
case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: //nolint:gosec // seq is a counter bounded well below MaxInt64
var oldestSeq int64
if len(oldest) > 0 {
oldestSeq = oldest[0].Seq
}
slog.Warn("ws handleReconnect: retention pruning left a gap before last_seq, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq, "oldest_seq", oldestSeq)
default:
persistedTail, maxPersistedSeq = h.reconnectVetColdTail(ctx, c, es, lastSeq, persisted, allowedChannelIDs)
if persistedTail != nil {
events = persistedTail
replaySource = "db"
}
}
}
}
return events, replaySource, persistedTail, maxPersistedSeq
}
// reconnectVetColdTail turns a cold-tier result into a replayable tail, or
// returns nil when it cannot be trusted: the range it covers must have no
// interior gap, and the ring buffer must cover everything newer than its last
// row. The returned seq is the highest one in persisted.
func (h *Hub) reconnectVetColdTail(
ctx context.Context, c *Client, es EventStore, lastSeq uint64,
persisted []db.PersistedEvent, allowedChannelIDs map[int64]bool,
) ([][]byte, uint64) {
persistedTail := make([][]byte, 0, len(persisted))
for _, p := range persisted {
persistedTail = append(persistedTail, p.Payload)
}
maxPersistedSeq := uint64(persisted[len(persisted)-1].Seq) //nolint:gosec // seq is a counter bounded well below MaxInt64
// persisted is channel-filtered, so a hole in a channel
// outside allowedChannelIDs would slip past a contiguity
// check on persisted itself — and EventPersister can lose a
// row outright (a full queue drops silently in Enqueue, a
// per-row insert failure inside a batch flush is logged but
// never surfaced here; see event_persister.go). Count the
// UNFILTERED range (lastSeq, maxPersistedSeq] and require
// every seq in it to be present. seq is the events table's
// primary key, so the count can only come up short, never
// over.
expectedCount := maxPersistedSeq - lastSeq
switch gapCount, gapErr := es.CountEventsInRange(ctx, int64(lastSeq), int64(maxPersistedSeq)); { //nolint:gosec // bounded well below MaxInt64
case gapErr != nil:
slog.Warn("ws handleReconnect: cold-tier contiguity probe failed, forcing full ready",
"user_id", c.userID, "err", gapErr)
persistedTail = nil
case uint64(gapCount) != expectedCount: //nolint:gosec // bounded well below MaxInt64
slog.Warn("ws handleReconnect: cold-tier replay has an interior gap, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq, "max_persisted_seq", maxPersistedSeq,
"expected", expectedCount, "found", gapCount)
persistedTail = nil
}
if persistedTail != nil {
// The EventPersister flushes asynchronously, so cold rows can
// lag the live seq: events broadcast after the last flush sit
// only in the ring buffer. Confirm the buffer can cover
// everything above the newest persisted row — the
// authoritative re-read happens atomically with registerNow
// below, but a hole here must still force a full ready
// rather than a replay with a silent gap at its end.
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
case tail != nil:
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
// Post-restart empty buffer with the hub seq seeded from
// the store max: nothing was broadcast after the last
// persisted row, so the cold rows alone are complete.
default:
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail, forcing full ready",
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
persistedTail = nil
}
}
return persistedTail, maxPersistedSeq
}
// reconnectRegister re-reads the ring-buffer-derived portion of the replay and
// registers c inside the SAME h.seqMu critical section deliverBroadcast uses,
// so no seq can be allocated in between (see the comment in handleReconnect).
// It returns the events to actually send; ok=false means one of the re-checks
// tripped and the caller must fall through to a full ready.
func (h *Hub) reconnectRegister(
ctx context.Context, c *Client, lastSeq uint64, allowedChannelIDs map[int64]bool,
replaySource string, persistedTail [][]byte, maxPersistedSeq uint64,
) ([][]byte, bool) {
var events [][]byte
h.seqMu.Lock()
switch replaySource {
case "buffer":
fresh := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs)
if fresh == nil {
// The buffer window closed between the earlier check and this
// lock (an extreme write burst evicted lastSeq) — there is
// nothing left to fall back to for this attempt but a full ready.
h.seqMu.Unlock()
slog.Warn("ws handleReconnect: buffer window closed just before registration, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq)
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return nil, false
}
events = fresh
case "db":
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
case tail != nil:
events = append(append([][]byte{}, persistedTail...), tail...)
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
events = persistedTail
default:
h.seqMu.Unlock()
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail just before registration, forcing full ready",
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return nil, false
}
}
if handleReconnectPreRegisterRaceHook != nil {
handleReconnectPreRegisterRaceHook()
}
// Re-check the watermark one last time, right before registerNow makes
// this connection reachable. RefreshChannelVisibility and
// revokeUnreadableChannels both iterate h.clients to fan out a targeted,
// unsequenced channel_create/channel_delete — a snapshot this
// still-mid-handshake connection is absent from — and both only bump the
// watermark afterward. Without this re-check, a visibility change that
// lands anywhere between the entry check above and here is missed twice:
// the fan-out can't reach an unregistered client, and the entry check has
// already passed, so nothing else catches it before this resume commits
// to permissions computed before the change (OC-0206).
if h.mustFullResync(lastSeq) {
h.seqMu.Unlock()
slog.Warn("ws handleReconnect: visibility changed during handshake, forcing full ready",
"user_id", c.userID, "last_seq", lastSeq)
h.reconnectTierFull.Add(1)
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
return nil, false
}
h.registerNow(c, allowedChannelIDs)
h.seqMu.Unlock()
return events, true
}
// reconnectWriteReplay writes the resume handshake: auth_ok followed by the
// replayed events. A false return means a write failed, in which case the full
// unregisterFailedHandshake teardown has already run and conn is closed, so the
// caller must not start any pump (OC-0051).
func (h *Hub) reconnectWriteReplay(
ctx context.Context, conn *websocket.Conn, c *Client, lastSeq uint64,
events [][]byte, replaySource string,
) bool {
// Replay succeeded — send auth_ok then missed events. The replay tier
// is included in the payload so the client can attribute reconnect
// behaviour without separate metric scraping.
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource)
if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {
slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err)
h.unregisterFailedHandshake(ctx, c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return false
}
for _, evt := range events {
if err := handshakeWrite(ctx, conn, evt); err != nil {
slog.Warn("ws: failed to send replay event", "user_id", c.userID, "err", err)
h.unregisterFailedHandshake(ctx, c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return false
}
}
slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource)
return true
}
// liveVoiceEventsSince returns voice_state/voice_leave events for chID at or
// after afterSeq, bypassing the READ-gated channel filter entirely. Voice
// membership needs only CONNECT_VOICE (voice_join.go), so a resuming
// participant's own room is not always in their READ-visible set — a stock
// example is a DM voice call after the DM was closed. Tries the ring buffer
// first (fresh, so it observes anything pushed concurrently with the caller),
// then falls back to the cold-tier store; returns nil, not an error, on a
// miss in both, since this is a best-effort supplement to the main replay.
func (h *Hub) liveVoiceEventsSince(ctx context.Context, afterSeq uint64, chID int64) [][]byte {
if chID == 0 {
return nil
}
only := map[int64]bool{chID: true}
var raw [][]byte
if buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {
raw = buf
} else if esp := h.eventStore.Load(); esp != nil {
es := *esp
coldCap := h.maxColdReplayLimit()
// Fetch one row past the cap so truncation is decided by the presence
// of that extra row, not by len == cap: a complete window of exactly
// coldCap rows is not truncated and must replay in full (Codex review
// on #1436). A result of at most coldCap rows is therefore complete.
persisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, coldCap+1) //nolint:gosec // afterSeq is a sequence counter bounded well below MaxInt64
if err != nil {
return nil
}
if len(persisted) > coldCap {
// Same failure mode reconnectSelectReplay guards against above:
// the query is "ORDER BY seq ASC LIMIT n", so a result past the
// cap means the range exceeds it and any cap-sized window would
// have silently dropped the NEWEST rows — for a voice room, quite
// possibly the peer's voice_leave. Replaying a truncated window
// would install a join whose matching leave was discarded, which
// is worse than the documented best-effort miss this function
// already returns on a plain lookup failure. A full ready isn't
// available here (registerNow already ran before this supplement
// runs), so nil is the correct degradation.
slog.Warn("ws liveVoiceEventsSince: cold-tier supplement exceeds the row cap, skipping truncated window",
"chID", chID, "after_seq", afterSeq, "cap", coldCap)
return nil
}
raw = make([][]byte, 0, len(persisted))
for _, p := range persisted {
raw = append(raw, p.Payload)
}
}
if len(raw) == 0 {
return nil
}
filtered := make([][]byte, 0, len(raw))
for _, evt := range raw {
switch extractEventType(evt) {
case MsgTypeVoiceState, MsgTypeVoiceLeaveBC:
filtered = append(filtered, evt)
}
}
return filtered
}
// unregisterFailedHandshake removes c after a post-registerNow handshake
// write failure. No readPump ever starts for this connection — the
// fresh-connect callers return an error that stops ServeWS before it starts
// the pumps, and handleReconnect's callers report startPumps=false for the
// same reason (OC-0051) — and the old connection this one replaced already
// ran its defer (skipping teardown because this client held the slot) — so
// when no replacement remains, the standard disconnect teardown must run
// here or the user stays online forever.
func (h *Hub) unregisterFailedHandshake(ctx context.Context, c *Client) {
// Snapshot voice state BEFORE unregister, mirroring readPump's defer
// (serve_pumps.go): once unregisterNow removes c, there is no way to tell
// whether it still owned a (possibly just-transferred) voice session.
voiceChID := c.getVoiceChID()
replaced := h.unregisterNow(c)
if !replaced {
cleanupCtx := context.WithoutCancel(ctx)
// A connection that inherited a transferred voice session (the
// replay-failure fallback in handleFreshConnect deliberately keeps
// the voice_states row and registerNow transfers it onto c) must have
// that session torn down here too, or the row, the LiveKit
// participant, and a stale E2EE key-holder entry all survive this
// connection's death until the next sweep (up to 60s).
if voiceChID != 0 {
h.handleVoiceLeave(cleanupCtx, c)
}
}
// shouldMarkOffline re-checks h.clients rather than trusting the
// `replaced` snapshot alone: it was sampled before handleVoiceLeave,
// which can block for seconds, so a reconnect landing during that window
// would otherwise be invisible here and mark the live session's user
// offline (OC-0019, mirrored from readPump's defer in serve_pumps.go).
if h.shouldMarkOffline(c, replaced) {
cleanupCtx := context.WithoutCancel(ctx)
_ = h.db.MarkUserDisconnected(cleanupCtx, c.userID)
// custom_status is nil, not c.user.CustomStatus: see the identical
// note in serve_pumps.go's readPump defer — that field is an
// auth-time snapshot, never updated, so broadcasting it here can
// resurrect a status the user already changed or cleared.
h.QueuePresence(c.userID, db.StatusOffline, nil)
}
}
// applyConnectStatus writes the status this session comes online as and caches
// it on the client.
//
@@ -181,3 +755,236 @@ func applyConnectStatus(ctx context.Context, database *db.DB, c *Client) {
func (h *Hub) announceConnectPresence(c *Client) {
h.QueuePresence(c.userID, c.user.Status, c.user.CustomStatus)
}
// computeAllowedChannels returns the set of channel IDs a user may access,
// including both server channels (filtered by ReadMessages permission) and
// the user's open DM channels. The server-channel set comes from the single
// permissions.Checker predicate shared with buildReady and REST
// ListVisibleChannels, so replay-buffer filtering can never drift from the
// ready payload's visible channels.
func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user *db.User) (map[int64]bool, error) {
channels, err := database.ListChannels(ctx)
if err != nil {
return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err)
}
role, err := database.GetRoleByID(ctx, user.RoleID)
if err != nil {
return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err)
}
// Nil role = zero access (fail closed). Admins skip the override fetch.
allowed := make(map[int64]bool)
if role != nil {
var overrides map[int64]db.ChannelOverride
if !permissions.HasAdmin(role.Permissions) {
overrides, err = database.GetChannelOverridesFor(ctx, role.ID, user.ID)
if err != nil {
return nil, fmt.Errorf("computeAllowedChannels GetChannelOverridesFor: %w", err)
}
}
allowed = h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides))
}
// Include the user's open DM channels. Only the ID set matters here, so
// use the PK-covered dm_open_state lookup instead of the full DM query.
// Fatal like the three sibling lookups above: a silently DM-stripped
// replay advances the client's lastSeq past DM events it never received —
// a permanent hole. The caller's error path falls back to full ready.
dmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID)
if dmErr != nil {
return nil, fmt.Errorf("computeAllowedChannels GetUserDMChannelIDs: %w", dmErr)
}
for _, id := range dmIDs {
allowed[id] = true
}
return allowed, nil
}
func (h *Hub) handleFreshConnect(
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB,
) error {
// Clean stale voice state BEFORE building ready and registering.
// When a user F5-reloads while in voice, the DB row from the previous
// session must be removed so the ready payload doesn't include it and
// other clients see a voice_leave broadcast.
if vs, err := database.GetVoiceState(ctx, c.userID); err == nil && vs != nil {
h.freshConnectCleanStaleVoice(ctx, database, c, vs)
}
// c.user is the auth-time snapshot — re-read it so the ready payload and
// any inherited subscriptions resolve from the user's CURRENT role, not
// the one they held when the auth frame was evaluated (audit-2026-08-19
// F-2; the resume path does the same in reconnectPrecheck). Fail closed
// like the role lookup below.
if err := h.refreshUserSnapshot(ctx, database, c); err != nil {
slog.Error("ws: user re-read failed, disconnecting", "user_id", c.userID, "err", err)
_ = conn.Close(websocket.StatusInternalError, "user lookup failed")
return err
}
// Look up role for permission-filtered ready payload.
// Fail closed: if the role lookup fails, disconnect rather than serving
// a permissive ready payload with nil role (BUG-094).
userRole, roleErr := database.GetRoleByID(ctx, c.user.RoleID)
if roleErr != nil || userRole == nil {
slog.Error("ws: role lookup failed, disconnecting", "user_id", c.userID, "role_id", c.user.RoleID, "err", roleErr)
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
return fmt.Errorf("role lookup failed for user %d: %w", c.userID, roleErr)
}
// Register BEFORE writing auth_ok + ready so broadcasts that arrive during
// the write window are queued in the client's send buffer instead of
// being lost (BUG-123). writePump hasn't started yet, so queued messages
// will be drained once the pumps begin.
//
// Only the replay-failure fallback (lastSeq > 0) can inherit voice state
// from the previous connection, so that is the only case where registerNow
// needs the read-permission set. Fail closed on error: nil denies the
// inherited voice-channel subscription.
var allowedChannelIDs map[int64]bool
if c.lastSeq > 0 {
allowed, allowedErr := h.computeAllowedChannels(ctx, database, c.user)
if allowedErr != nil {
slog.Warn("ws handleFreshConnect: computeAllowedChannels failed, skipping voice channel subscription",
"user_id", c.userID, "err", allowedErr)
} else {
allowedChannelIDs = allowed
}
}
// handleReconnect may have promoted an auth-frame active_channel_id into
// c.channelID (serve.go, honoured only when it was READ-visible at that
// moment) and then aborted on one of its own re-checks — most notably the
// final mustFullResync check, tripped by a permission revocation that
// landed mid-handshake. None of those abort paths undo the c.channelID
// write. registerNow subscribes c.channelID's ChannelTopic
// unconditionally, so re-gate it here against the freshly recomputed
// permission set before registering. Fail closed: a nil allowedChannelIDs
// (lastSeq == 0, or the computeAllowedChannels error branch above) denies.
if chID := c.getChannelID(); chID != 0 && !allowedChannelIDs[chID] {
c.mu.Lock()
c.channelID = 0
c.mu.Unlock()
}
if freshConnectPreRegisterRaceHook != nil {
freshConnectPreRegisterRaceHook()
}
h.registerNow(c, allowedChannelIDs)
// The re-read above and registerNow are not atomic: a role reassignment
// committing in between finds this socket absent from h.clients (so its
// revokeUnreadableChannels pass early-returns) yet builds our inherited
// subscriptions from the pre-change role. One PK re-read after
// registration makes the two orderings meet: a commit visible here is
// pruned by our own revoke pass, and a commit that is not yet visible
// necessarily runs its own revoke lookup after our registerNow and
// finds us.
// Scoped to the resume-fallback path — a pure fresh connect (lastSeq==0)
// inherits no subscriptions; channel_focus and voice_join re-check live.
if c.lastSeq > 0 {
if fresh, err := database.GetUserByID(ctx, c.userID); err != nil || fresh == nil || fresh.RoleID != c.user.RoleID {
//nolint:contextcheck // revokeUnreadableChannels takes no context by design (admin HubBroadcaster interface).
h.revokeUnreadableChannels(c.userID)
}
}
// Settle the session's status before buildReady reads the member list, so
// the ready payload and the presence broadcast below cannot disagree.
applyConnectStatus(ctx, database, c)
// Fresh connection or replay fallback: full auth_ok + ready flow.
slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName)
if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil {
slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err)
h.unregisterFailedHandshake(ctx, c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return err
}
if ready, readyErr := h.buildReady(ctx, database, c.userID, userRole); readyErr == nil {
slog.Info("ws sending ready payload", "user_id", c.userID, "payload_bytes", len(ready))
if err := handshakeWrite(ctx, conn, ready); err != nil {
slog.Warn("ws: failed to send ready payload", "user_id", c.userID, "err", err)
h.unregisterFailedHandshake(ctx, c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return err
}
} else {
slog.Error("buildReady failed", "user_id", c.userID, "err", readyErr)
_ = handshakeWrite(ctx, conn, buildErrorMsg(ErrCodeInternal, "failed to build ready payload"))
h.unregisterFailedHandshake(ctx, c)
_ = conn.Close(websocket.StatusInternalError, "failed to build ready payload")
return readyErr
}
slog.Info("ws broadcasting member_join and presence", "user_id", c.userID, "username", c.user.Username)
h.BroadcastToAll(buildMemberJoin(c.user, c.roleName))
h.announceConnectPresence(c)
return nil
}
// freshConnectCleanStaleVoice removes the voice state left behind by this
// user's previous session, unless that session is the still-registered
// connection this one is about to inherit from.
func (h *Hub) freshConnectCleanStaleVoice(ctx context.Context, database *db.DB, c *Client, vs *db.VoiceState) {
// Replay-failure fallback (lastSeq > 0): registerNow below transfers
// the still-registered old connection's live voice state into this
// client. Deleting the DB row here — and the LiveKit participant,
// whose removal token is the very JoinedAt being transferred — would
// leave the user "in voice" on the hub only: voice_join bounces off
// ALREADY_JOINED and sweepStaleVoiceStates never heals
// memory-without-row. Keep the row so ready stays consistent. If the
// old client unregisters before registerNow runs, the transfer is
// skipped and the next sweep reaps the then-truly-stale row.
if old := h.GetClient(c.userID); c.lastSeq > 0 && old != nil && old.getVoiceChID() == vs.ChannelID {
slog.Info("ws fresh connect: keeping voice state for replay-failure fallback",
"user_id", c.userID, "channel_id", vs.ChannelID)
return
}
slog.Info("ws fresh connect: cleaning stale voice state",
"user_id", c.userID, "channel_id", vs.ChannelID)
if _, delErr := database.LeaveVoiceChannelIfMatch(ctx, c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil {
slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr)
}
// The DB row is gone, but the still-registered OLD *Client (if any) is
// otherwise only cleared by registerNow — which two early-return paths
// further down handleFreshConnect (the refreshUserSnapshot and
// GetRoleByID failure branches) can skip entirely. Without this, that
// old client's in-memory voiceChID and the E2EE key-holder election for
// this room survive as a memory-without-row ghost that
// sweepStaleVoiceStates can never see, since it iterates DB rows
// (OC-0252). Clearing here makes freshConnectCleanStaleVoice self
// sufficient regardless of whether registerNow ever runs; registerNow's
// own replacedVoiceChID re-election later becomes a redundant no-op
// (clearVoiceState finds nothing left to clear), not a conflict.
if old := h.GetClient(c.userID); old != nil {
if _, cleared := old.clearVoiceStateIfMatch(vs.ChannelID); cleared {
h.pubsub.Unsubscribe(old, VoiceTopic(vs.ChannelID))
}
}
h.updateKeyHolder(vs.ChannelID)
h.broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceLeave(vs.ChannelID, c.userID))
if h.livekit == nil {
return
}
// BUG-089: Capture stale join token so the goroutine only removes
// the exact stale participant. The identity includes joinedAt, so
// even if the user rejoins voice quickly, the new session has a
// different identity and won't be removed. The removal must
// complete even if this connection drops mid-handshake, so detach
// from cancellation (values kept); shutdown is handled via h.stop.
staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt
lkCtx := context.WithoutCancel(ctx)
go func() {
select {
case <-h.stop:
return
default:
}
if err := h.livekit.RemoveParticipant(lkCtx, staleChID, staleUserID, staleJoinToken); err != nil {
slog.Warn("ws fresh connect: RemoveParticipant failed (may already be gone)",
"err", err, "user_id", staleUserID, "channel_id", staleChID)
}
}()
}
-102
View File
@@ -4,9 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/coder/websocket"
@@ -106,102 +103,3 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
return user, hash, resumeHint{LastSeq: p.LastSeq, ChannelID: p.ActiveChannelID}, nil
}
// handshakeWrite writes one handshake-phase message (auth_ok, ready, or a
// replay event) under writeTimeout, instead of the bare ctx every caller here
// otherwise has on hand.
//
// Every handshake write runs against ctx = r.Context() from ServeWS.
// websocket.Accept hijacks the connection, which stops net/http's own
// mechanism for cancelling that context on client disconnect, so without this
// wrapper ctx is never cancelled while the handler is blocked inside
// conn.Write — a peer that stops reading (or whose receive window closes)
// pins the write, the handler goroutine, and the socket forever (OC-0152).
// writePumpWrite (serve_pumps.go) already bounds its writes the same way;
// this brings the handshake writes in serve.go up to the same guarantee.
func handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error {
wCtx, cancel := context.WithTimeout(ctx, writeTimeout)
defer cancel()
return conn.Write(wCtx, websocket.MessageText, msg)
}
func (h *Hub) upgradeAndAuth(
conn *websocket.Conn, database *db.DB, r *http.Request,
) (*Client, uint64, error) {
user, tokenHash, hint, err := authenticateConn(r.Context(), conn, database)
if err != nil {
slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr)
_ = conn.Close(websocket.StatusPolicyViolation, "authentication failed")
return nil, 0, err
}
lastSeq := hint.LastSeq
c := newClient(h, conn, user, tokenHash, lastSeq, r.Context())
c.remoteAddr = r.RemoteAddr
// Untrusted until handleReconnect checks it against the allowed set.
c.authChannelID = hint.ChannelID
// Look up role name for protocol-compliant payloads and cache on client.
// Fail closed like the sibling lookup in handleFreshConnect (BUG-094):
// this value is authoritative on the wire — auth_ok reports it as the
// user's own role, member_join broadcasts it to every other client, and
// every chat_message carries it — so a lookup failure must not silently
// substitute "member" and pin the whole session to a fabricated role
// (OC-0269).
role, roleErr := database.GetRoleByID(r.Context(), user.RoleID)
if roleErr != nil || role == nil {
slog.Error("ws: role lookup failed during handshake, closing connection",
"user_id", user.ID, "role_id", user.RoleID, "err", roleErr)
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
return nil, 0, fmt.Errorf("upgradeAndAuth: role lookup failed for user %d: %w", user.ID, roleErr)
}
c.roleName = strings.ToLower(role.Name)
slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr)
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "ws_connect", "user", user.ID,
"WebSocket connected from "+r.RemoteAddr)
return c, lastSeq, nil
}
// unregisterFailedHandshake removes c after a post-registerNow handshake
// write failure. No readPump ever starts for this connection — the
// fresh-connect callers return an error that stops ServeWS before it starts
// the pumps, and handleReconnect's callers report startPumps=false for the
// same reason (OC-0051) — and the old connection this one replaced already
// ran its defer (skipping teardown because this client held the slot) — so
// when no replacement remains, the standard disconnect teardown must run
// here or the user stays online forever.
func (h *Hub) unregisterFailedHandshake(ctx context.Context, c *Client) {
// Snapshot voice state BEFORE unregister, mirroring readPump's defer
// (serve_pumps.go): once unregisterNow removes c, there is no way to tell
// whether it still owned a (possibly just-transferred) voice session.
voiceChID := c.getVoiceChID()
replaced := h.unregisterNow(c)
if !replaced {
cleanupCtx := context.WithoutCancel(ctx)
// A connection that inherited a transferred voice session (the
// replay-failure fallback in handleFreshConnect deliberately keeps
// the voice_states row and registerNow transfers it onto c) must have
// that session torn down here too, or the row, the LiveKit
// participant, and a stale E2EE key-holder entry all survive this
// connection's death until the next sweep (up to 60s).
if voiceChID != 0 {
h.handleVoiceLeave(cleanupCtx, c)
}
}
// shouldMarkOffline re-checks h.clients rather than trusting the
// `replaced` snapshot alone: it was sampled before handleVoiceLeave,
// which can block for seconds, so a reconnect landing during that window
// would otherwise be invisible here and mark the live session's user
// offline (OC-0019, mirrored from readPump's defer in serve_pumps.go).
if h.shouldMarkOffline(c, replaced) {
cleanupCtx := context.WithoutCancel(ctx)
_ = h.db.MarkUserDisconnected(cleanupCtx, c.userID)
// custom_status is nil, not c.user.CustomStatus: see the identical
// note in serve_pumps.go's readPump defer — that field is an
// auth-time snapshot, never updated, so broadcasting it here can
// resurrect a status the user already changed or cleared.
h.QueuePresence(c.userID, db.StatusOffline, nil)
}
}

Some files were not shown because too many files have changed in this diff Show More