Author SHA1 Message Date
J3vbandClaude Fable 5 63c87df487 refactor(b3-8): settings/audit family behind SettingsService (S-09, family 1) (#1477)
* feat(service): settings family — SettingsService over the Store seam

The B3-8 settings/audit family's service: List, Patch (whitelist,
boolean normalization, the require_2fa preconditions incl. the TOTP
census and the unrelated-key guard, atomic apply, one audit row per
changed key) and Setting (the read the hub and the backup scheduler
consume; wraps db.ErrNotFound as the store reports it). db gains
ApplySettings — the handler's raw upsert loop as one hand-written
transactional wrapper where raw SQL belongs — and Store carries it.

parseSettingsPatchBool duplicates auth.go's parseBooleanSettingValue
with the admin surface's own pinned error wording; both messages are
test-pinned, so the twins stay separate.

Service-level characterization in settings_test.go mirrors the
admin/api_test.go PATCH rows and adds the service-only contracts
(ErrNotFound wrap, audit rows, multi-key apply).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(admin): settings handlers thin over SettingsService; scheduler reads via it

handleGetSettings/handlePatchSettings become adapters (decode, delegate,
map ErrBadRequest to 400 with the service's prefix-free message); the
whitelist and every precondition now live only in the service, so
admin/types.go's copy is gone. MaintainBackups reads backup_schedule and
backup_retention through the service — its backup mechanics keep the
handle — and the maintenance chain threads Settings from the runtime the
hub stage built. NewHandler/NewAdminAPI gain the settings parameter;
all 207 construction sites wired via the newTestSettingsService helper.

Behavior parity pinned by the existing TestAdminAPI_*Settings* rows
(all green); the only unpinned change is the PATCH 500 path collapsing
its four stage-specific internal messages into one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(ws): hub settings cache reads through a SettingsReader

The hub's server_name/motd cache consumes a consumer-side SettingsReader
interface (service.SettingsService satisfies it; HubOptions.Settings is
required and validated like DB and Limiter — the RequiredCollaborators
pin gains the refusal case). hub_settings.go no longer touches db at
all, so the import pin from the B3-5 finisher goes, and its allowlist
row goes with it; the thinned admin settings handler's row is deleted
too — two allowlist rows down, the settings family's persistence now
lives only in db/ and service/.

Test helpers (both ws package namespaces) default the reader over the
test database; newBareHub wires it explicitly; production passes
Services.Settings from StartRuntime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(boundaries,b3): settings/audit family re-measure and evidence

The backup pair takes its forecast boundary disposition; the family's
two deleted rows and the disposition counts (28/18/15 -> 24/18/17)
re-derived from the tool. Family evidence block appended to the B3-8
section; README B3 row records B3-5 complete and the family opened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* fix(service): prefix-free ErrBadRequest wraps for the pinned admin bodies

The %.0w rework was meant to ride the service commit but was left
unstaged: with the plain %w wrap the PATCH error bodies carry a
'bad request: ' prefix the admin pins reject. Zero-width wrapping keeps
errors.Is(ErrBadRequest) while err.Error() stays exactly the pinned
message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* test(app): lifecycle hub fixtures wire the required Settings reader

The two direct ws.NewHub sites in lifecycle_test predate Settings
becoming required; race across internal/app is green again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* test(db): cover ApplySettings — the db coverage floor caught the gap

CI's coverage floor failed db at 78.9% against 79.3%: ApplySettings was
exercised only from service tests, which do not count toward db's own
figure. Four db-side rows cover the apply, the empty no-op, the
in-transaction failure rollback and the begin failure, using the
package's full-migration opener.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* chore(coverage): raise the service floor to the branch's measured 69.2

The settings family's tested service code raised the Linux figure from
the 67.8 floor to 69.2; the ratchet raises the floor in the same PR
(service is not in the run-varying set). db stays at 79.3 — this PR
restores its figure (79.5 with the ApplySettings tests), it did not set
out to raise it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 15:13:38 +00:00
J3vbandClaude Fable 5 528ae260ee refactor(b3-5): hub.go under 400 — construction, settings cache and replay accessor move out (S-08, finisher) (#1476)
* refactor(ws): move construction, settings cache and replay accessor out of hub.go

Pure moves closing hub.go's B3-5 size target: HubOptions and NewHub ->
new hub_options.go; getCachedSettings and refreshSettingsLocked -> new
hub_settings.go; maxColdReplayLimit -> replay.go beside the family that
reads it. Normalized-diff residue: the new files' scaffolding plus
duplicates of five imports hub.go retains (auth, db, permissions,
plugin, service); errors, fmt and os moved outright. No identifier
changed.

hub.go now holds the Hub state, lifecycle (Run/Stop/graceful) and stats
accessors: 361 lines, under the 400-line exit target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(boundaries,b3): re-measure after the hub.go finishers; record the B3-5 exit

hub.go and hub_options.go become type-only boundary rows;
hub_settings.go reads through the h.db field and imports nothing, so it
carries no row (the header records that wrinkle). Disposition counts
re-derived from the tool's summary — boundary had been stale at 12 since
the seed-profile row. Finisher evidence block and the B3-5 exit summary
(five squash merges, all seven responsibilities, exit size table with
all three targets met) appended to the plan; README B3 row advanced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 14:09:11 +00:00
J3vbandClaude Fable 5 df717e431c refactor(b3-5): voice leftovers join voice_broadcast.go; presence coalescer to hub_presence.go (S-08, PR 4) (#1475)
* refactor(ws): move voice broadcast leftovers into voice_broadcast.go

Pure move of broadcastVoiceEvent and broadcastVoiceEventWithLeaver from
hub_broadcast.go and VoiceSessionCount from hub.go into the existing
voice_broadcast.go, beside the voice rate-limit and quality tables.
Normalized-diff residue: one context import added to the destination;
the sources keep theirs. No identifier changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(ws): move the presence coalescer into hub_presence.go

Pure move of pendingPresence, QueuePresence,
dropQueuedPresenceAndBroadcast, presenceCoalesceWindow,
presenceFlushRaceHook, flushPresenceQueue and BroadcastPresence from
hub_broadcast.go into the new hub_presence.go, in source order.
Normalized-diff residue: the new file's scaffolding plus duplicates of
two imports the source keeps (time, db). No identifier changed.

hub_broadcast.go now holds only broadcast delivery and backpressure —
424 lines, under its 500-line B3-5 exit target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* chore(invariants): hub_presence.go adapter row; hub_broadcast reason trimmed

The presence coalescer makes no persistence call — db.BroadcastStatus is
the doc's own pure-helper example — so the new file is an adapter row;
hub_broadcast.go's reason drops the presence half it no longer does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(boundaries,b3): re-measure after the voice and presence moves

Table regenerated (hub_presence.go adapter row, 17 -> 18; hub_broadcast
reason trimmed), header dated, split-PR-4 evidence block appended,
README B3 row advanced. hub_broadcast.go records its exit figure met
(424 < 500).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 15:25:29 +02:00
J3vbandClaude Fable 5 70875a36f6 refactor(b3-5): gather ws visibility and permission refresh into hub_visibility.go (S-08, PR 3) (#1474)
* refactor(ws): gather visibility and permission refresh into hub_visibility.go

Pure move of the visibility responsibility from the three files it was
spread across: the channelReadAudience family, RefreshChannelVisibility,
refreshChannelVisibilityCanSend, RefreshAllChannelVisibility and
revokeUnreadableChannels from hub_broadcast.go; computeAllowedChannels
from serve.go; bumpVisibilityWatermark and MarkVisibilityChanged from
hub.go. Normalized-diff residue: the new file's scaffolding plus
duplicates of five imports the sources keep (context, fmt, db, log/slog,
sync/atomic); the permissions import moved outright (out of
hub_broadcast.go and serve.go, into hub_visibility.go). No identifier
changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(boundaries,b3): re-measure after the visibility gather

hub_visibility.go gains its DBImportAllow row; hub_broadcast.go's row
shrinks to the member/presence payload reads. Table regenerated, counts
and ratchet wording brought current (27 -> 28 move rows, zero new
calls), re-measurement header dated. Split-PR-3 evidence block appended
to the B3 plan; README B3 row advanced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 13:59:33 +02:00
J3vbandClaude Fable 5 f9258efc6e refactor(b3-5): split ws — replay family and connection registry move to their files (S-08, PR 2) (#1473)
* refactor(ws): move reconnect replay selection and delivery into replay.go

Pure move of handleReconnect, reconnectPrecheck, reconnectSelectReplay,
reconnectVetColdTail, reconnectRegister, reconnectWriteReplay and
liveVoiceEventsSince — plus the replay-only maxColdReplay const, cut from
serve.go's shared const block — into the new replay.go. Normalized-diff
residue: the new file's scaffolding (package, import and const openers)
and duplicates of four imports serve.go retains (context, log/slog,
db, websocket); sync/atomic and telemetry moved outright. No identifier
changed.

serve.go keeps ServeWS, the shared connect-time helpers
(refreshUserSnapshot, applyConnectStatus, announceConnectPresence) and
computeAllowedChannels (visibility, moves with that responsibility).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(ws): move the connection registry and supersession out of hub.go

Pure move of Register, Unregister, registerNow, unregisterNow and
shouldMarkOffline into the new hub_registry.go. Normalized-diff residue:
the new file's package line and its log/slog import (hub.go keeps its
copy for the remaining code). No identifier changed.

The plan named registry.go as the destination, but that file holds the
message-type HandlerRegistry — a different registry; growing it would
conflate the two. hub_registry.go keeps them apart; deviation recorded
in the plan's evidence block.

clientEvent stays in hub.go: Run's dispatch loop owns the channel the
queued events flow through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(boundaries,b3): re-measure after the replay and registry moves

replay.go gains its DBImportAllow row (a split of serve.go's row,
type-only — the family's calls stayed with the shared helpers);
hub_registry.go needs none (no db use). Table regenerated, the
connection-rows note updated, the ratchet sentence now names the db
surface rather than the row count (26 -> 27 move rows, zero new calls),
disposition count and re-measurement header brought current. Split-PR-2
evidence block appended to the B3 plan; README B3 row advanced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 13:19:28 +02:00
J3vb 0518e689d0 docs(register): enumerate OC-0349–0375 with owner-approved phases; identical-tree G-03; slim BG-07 (#1471)
28 register rows with phase ownership, the G-03/BG-07 rewords, and the
B1-exit strict-pin wording made precise (what tree identity detects; the
owner reapply step at hold points, from Codex's P1).
2026-08-31 12:59:57 +02:00
J3vb 1d1804ce5b refactor(b3-5): move handshake auth and fresh-connect out of ws serve.go (S-08, split 1) (#1472)
Pure moves into serve_auth.go and serve_ready.go with the boundaries
inventory re-measured; evidence in the B3 plan's split-PR-1 block.
2026-08-31 12:35:28 +02:00
J3vbandClaude e13adaf8b1 refactor(b3-4): hub constructor options — required collaborators validated at construction (S-11) (#1470)
ws.NewHub(opts HubOptions) (*Hub, error). The dispositions came from what
each setter's own implementation said, not the plan's guesses:

- The four rejectIfRunning-guarded knobs were construction wiring
  pretending to be mutable state and became validated options with their
  setters deleted: SetLiveKit, SetLiveKitProcess, SetPluginRegistry,
  ConfigureReplay. rejectIfRunning died with its last caller. The plan
  left SetLiveKitProcess "depends on whether the supervised process can
  restart" — it cannot: the restart path relaunches the whole app, so a
  process without a client is now refused at construction.
- The genuinely runtime-mutable stay setters, each with its why:
  SetEventPersister / SetEventStore / SetPluginEventSink are atomic
  hot-swaps internal/app wires one lifecycle stage after Run (the
  persister cannot exist before the hub; the sink consumes the built
  hub's broadcaster), and SetPendingVoiceModFlags is per-user state.
- DB and Limiter are required — before this change ws.NewHub(nil, nil,
  nil) succeeded (api's LiveKit-proxy tests built exactly that hub) and
  a missing collaborator surfaced as a later panic. Services stays
  optional: nil is the degraded fixture half the ws suite builds, and
  forcing a real service layer would change which handler paths the
  frozen tests take.

internal/app.StartRuntime builds the LiveKit client/process first
(buildVoice), passes everything through HubOptions, and starts the
supervised process only once the hub holds it (OC-0019 ordering kept);
construction failure is a startHub boot error now. Test migration:
newTestHub / newTestHubDeps / newTestHubWith keep the old call shape at
~76 sites across both ws package namespaces; the eighteen former setter
sites construct with options; TestNewHub_RequiredCollaborators and
TestHub_LiveKitProcessRequiresClient pin the refusals.

server-boundaries.md gains the after-B3-4 setter table; the plan's B3-4
section gains the dated evidence block.


Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 08:47:24 +00:00
J3vbandClaude Fable 5 ed69bb56c7 fix(admin): owner gate consumes the context role — no second lookup (OC-0379) (#1467)
* fix(admin): owner gate consumes the context role — no second lookup (OC-0379)

OC-0345's fix kept the redundant GetRoleByID its own title named (its
suggestedFix says why: reading adminRoleKey broke two tests that injected
only the user). Finish the job: ownerOnlyMiddleware now consumes the
*db.Role adminAuthMiddleware stored, exactly like requirePerm — missing
role fails closed as 401, position below Owner stays 403, and the query
plus its 503 branch are gone (a role read fault surfaces once, at the
perimeter). The signature drops *db.DB at all nine call sites, so a
reintroduced lookup is a compile-visible change.

Test-first: TestOwnerOnlyMiddleware_NoSecondRoleLookup renames the roles
table away with the role in context and demands 200 — red 503 against the
old code, green now. The old RoleLookupFailureIs503 test guarded a branch
that no longer exists in any form; a tombstone comment records where its
contract lives on (the perimeter's default branch). Added below-owner 403
and user-without-role 401 pins; the blackbox owner-route tests pass
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* chore(ledger): record OC-0379 fixed; counts to 321/379 everywhere watched

New record for the residue bcdc0ef3 fixes, citing OC-0345's deliberate
half-fix; the register's truth table, its enumeration paragraph, and the
watched count lines in plans README, HP-0 and b0-baseline all move to
321 fixed / 379 total (check-doc-counts green, 21 claims agree).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(hp-0): date the 379-record provenance honestly (Codex P2 on #1467)

The path-resolution row and the count caption attributed OC-0379 to sweeps
that predate it; both now say what actually happened — 378 re-verified
2026-08-29, OC-0379 path-verified at its own 2026-08-31 fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 10:34:45 +02:00
J3vbandClaude bd397d2bbc feat(b3-7): alpha-shaped dataset — deterministic seed profile and the v1.2.0-alpha.4 snapshot (#1469)
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.


Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 06:12:09 +00:00
121 changed files with 3801 additions and 2789 deletions
+23 -1
View File
@@ -1,5 +1,5 @@
{
"nextId": 379,
"nextId": 380,
"findings": [
{
"id": "OC-0001",
@@ -8565,6 +8565,28 @@
"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, 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, settings *service.SettingsService, 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, opts...))
r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, roles, settings, 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))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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, 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, settings *service.SettingsService, 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(database, handleListAPITokens(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(handleListAPITokens(database)).ServeHTTP(w, req)
}))
r.Post("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleCreateAPIToken(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(handleCreateAPIToken(database)).ServeHTTP(w, req)
}))
r.Delete("/tokens/{id}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleRevokeAPIToken(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(handleRevokeAPIToken(database)).ServeHTTP(w, req)
}))
r.Group(func(r chi.Router) {
r.Use(requirePerm(permissions.ManageServer))
r.Get("/settings", handleGetSettings(database))
r.Patch("/settings", handlePatchSettings(database))
r.Get("/settings", handleGetSettings(settings))
r.Patch("/settings", handlePatchSettings(settings))
})
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(handleBackup(database)).ServeHTTP(w, req)
}))
r.Get("/backups", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleListBackups()).ServeHTTP(w, req)
ownerOnlyMiddleware(handleListBackups()).ServeHTTP(w, req)
}))
r.Delete("/backups/{name}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleDeleteBackup(database)).ServeHTTP(w, req)
ownerOnlyMiddleware(handleDeleteBackup(database)).ServeHTTP(w, req)
}))
r.Post("/backups/{name}/restore", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleRestoreBackup(database, hub)).ServeHTTP(w, req)
ownerOnlyMiddleware(handleRestoreBackup(database, hub)).ServeHTTP(w, req)
}))
r.Get("/updates", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleCheckUpdate(u)).ServeHTTP(w, req)
ownerOnlyMiddleware(handleCheckUpdate(u)).ServeHTTP(w, req)
}))
r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
ownerOnlyMiddleware(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json")))
w := httptest.NewRecorder()
+66 -60
View File
@@ -27,6 +27,12 @@ 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.
@@ -262,7 +268,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
@@ -285,7 +291,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
@@ -296,7 +302,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createMemberUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
@@ -310,7 +316,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil)
@@ -331,7 +337,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
// No query params — should use defaults
@@ -344,7 +350,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
w := doRequest(t, handler, http.MethodGet, "/users", "", nil)
@@ -362,7 +368,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
ownerToken := createAdminUser(t, database) // Owner role (pos 100)
// A second owner-rank user: equal position, cannot be banned.
@@ -425,7 +431,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
// Create a target user
@@ -453,7 +459,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "rolechange", "hash", 3)
@@ -475,7 +481,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]any{"banned": true}
@@ -488,7 +494,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil)
@@ -502,7 +508,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "logoutme", "hash", 3)
@@ -522,7 +528,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil)
@@ -535,7 +541,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
_, _ = database.AdminCreateChannel(context.Background(), "general", "text", "", "", 0)
@@ -559,7 +565,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]any{
@@ -586,7 +592,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]any{
@@ -603,7 +609,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "old", "text", "", "", 0)
@@ -624,7 +630,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]any{"name": "x"}
@@ -639,7 +645,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "del-me", "text", "", "", 0)
@@ -659,7 +665,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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
// The minimal admin schema has no voice_states; create it with the real
@@ -722,7 +728,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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "del-race", "voice", "", "", 0)
@@ -748,7 +754,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil)
@@ -762,7 +768,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
uid, _ := database.CreateUser(context.Background(), "actor", "hash", 1)
@@ -785,7 +791,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil)
@@ -805,7 +811,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/settings", token, nil)
@@ -827,7 +833,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -852,7 +858,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json")))
@@ -869,7 +875,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
// Admin (role 2) can authenticate but is not Owner (role 1, position 100)
adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2)
@@ -886,7 +892,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
@@ -903,7 +909,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
// Create a target user to act on.
@@ -937,7 +943,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "logoutctx", "hash", 3)
@@ -969,7 +975,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -995,7 +1001,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -1039,7 +1045,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
value := "testvalue"
@@ -1060,7 +1066,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]string{}
@@ -1073,7 +1079,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -1089,7 +1095,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil {
@@ -1109,7 +1115,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]string{
@@ -1131,7 +1137,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
// Enroll the admin so the initial require_2fa enable succeeds.
@@ -1177,7 +1183,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
// Create a second user so the list is non-trivial.
@@ -1204,7 +1210,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
@@ -1223,7 +1229,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
@@ -1252,7 +1258,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "patchvictim", "topsecretbcrypt", 3)
@@ -1280,7 +1286,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser(context.Background(), "patchtotp", "hash", 3)
@@ -1381,7 +1387,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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]any{
@@ -1404,7 +1410,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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
body := map[string]any{"name": "safe-channel", "type": "text"}
@@ -1418,7 +1424,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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "before", "text", "", "", 0)
@@ -1439,7 +1445,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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "patchme", "text", "", "", 0)
@@ -1454,7 +1460,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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "delete-me", "text", "", "", 0)
@@ -1474,7 +1480,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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "del-no-hub", "text", "", "", 0)
@@ -1503,7 +1509,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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "del-cancel-race", "text", "", "", 0)
@@ -1543,7 +1549,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database) // Owner role
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "ci-bot"})
@@ -1573,7 +1579,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": " "})
@@ -1588,7 +1594,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "neg-hours", "expires_hours": -1})
@@ -1609,7 +1615,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "huge-hours", "expires_hours": 3000000})
@@ -1627,7 +1633,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
hash := auth.HashToken("raw-secret-value")
@@ -1656,7 +1662,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
hash := auth.HashToken("revoke-me")
@@ -1678,7 +1684,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/tokens/99999", token, nil)
@@ -1692,7 +1698,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) // Admin, not Owner
token := "admin-only-token"
@@ -1706,7 +1712,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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
if err != nil {
+6 -5
View File
@@ -12,6 +12,7 @@ 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
@@ -35,8 +36,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) error {
schedule, err := database.GetSetting(ctx, "backup_schedule")
func MaintainBackups(ctx context.Context, database *db.DB, settings *service.SettingsService) error {
schedule, err := settings.Setting(ctx, "backup_schedule")
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil
@@ -60,7 +61,7 @@ func MaintainBackups(ctx context.Context, database *db.DB) error {
}
}
if err := pruneExpiredBackups(ctx, database); err != nil {
if err := pruneExpiredBackups(ctx, database, settings); err != nil {
slog.Warn("backup retention pruning failed", "error", err)
if firstErr == nil {
firstErr = err
@@ -118,8 +119,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) error {
retStr, err := database.GetSetting(ctx, "backup_retention")
func pruneExpiredBackups(ctx context.Context, database *db.DB, settings *service.SettingsService) error {
retStr, err := settings.Setting(ctx, "backup_retention")
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil
+8 -7
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/J3vb/OwnCord/Server/admin"
"github.com/J3vb/OwnCord/Server/service"
)
func listBackupFiles(t *testing.T, dir string) []string {
@@ -49,7 +50,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
ctx := context.Background()
// Settings absent → no-op, no error.
if err := admin.MaintainBackups(ctx, database); err != nil {
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
t.Fatalf("MaintainBackups with no settings: %v", err)
}
if got := listBackupFiles(t, dir); len(got) != 0 {
@@ -59,7 +60,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); err != nil {
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
t.Fatalf("MaintainBackups with schedule=off: %v", err)
}
if got := listBackupFiles(t, dir); len(got) != 0 {
@@ -68,7 +69,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); err != nil {
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
t.Fatalf("MaintainBackups daily #1: %v", err)
}
files := listBackupFiles(t, dir)
@@ -78,7 +79,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); err != nil {
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
t.Fatalf("MaintainBackups daily #2: %v", err)
}
if got := listBackupFiles(t, dir); len(got) != 1 {
@@ -88,7 +89,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); err != nil {
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
t.Fatalf("MaintainBackups daily #3: %v", err)
}
if got := listBackupFiles(t, dir); len(got) != 2 {
@@ -97,7 +98,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); err != nil {
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
t.Fatalf("MaintainBackups daily #4: %v", err)
}
got := listBackupFiles(t, dir)
@@ -132,7 +133,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); err != nil {
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
newTestModService(database), newTestRoleService(database), newTestSettingsService(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)
newTestModService(database), nil, newTestSettingsService(database))
token := createAdminUser(t, database)
for _, tc := range []struct {
+13 -142
View File
@@ -1,31 +1,31 @@
package admin
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/service"
)
// ─── 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(database *db.DB) http.HandlerFunc {
func handleGetSettings(settings *service.SettingsService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := database.GetAllSettings(r.Context())
all, err := settings.List(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
return
}
writeJSON(w, http.StatusOK, settings)
writeJSON(w, http.StatusOK, all)
}
}
func handlePatchSettings(database *db.DB) http.HandlerFunc {
func handlePatchSettings(settings *service.SettingsService) 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,144 +33,15 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc {
return
}
// 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 {
all, err := settings.Patch(r.Context(), actorFromContext(r), updates)
if errors.Is(err, service.ErrBadRequest) {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
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")
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update settings")
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)
writeJSON(w, http.StatusOK, all)
}
}
+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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, invalidator, newTestModService(database), newTestRoleService(database), newTestSettingsService(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)
newTestModService(database), roleSvc, newTestSettingsService(database))
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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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)
+11 -20
View File
@@ -116,31 +116,22 @@ func requirePerm(perm int64) func(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 {
// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(adminUserKey).(*db.User)
if !ok || user == nil {
role, ok := r.Context().Value(adminRoleKey).(*db.Role)
if !ok || role == 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
+89 -54
View File
@@ -12,6 +12,8 @@ import (
"path/filepath"
"runtime"
"testing"
"github.com/J3vb/OwnCord/Server/service"
"testing/fstest"
"github.com/J3vb/OwnCord/Server/auth"
@@ -138,38 +140,42 @@ CREATE TABLE IF NOT EXISTS invites (
// ─── ownerOnlyMiddleware whitebox tests ──────────────────────────────────────
// TestOwnerOnlyMiddleware_NoUserInContext verifies that ownerOnlyMiddleware
// returns 401 when there is no user stored in the request context.
// returns 401 when the request context carries no authenticated principal —
// simulates a call bypassing adminAuthMiddleware, which is what stores the
// role the gate consumes.
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(database, next)
handler := ownerOnlyMiddleware(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 missing user in context")
t.Error("next handler was reached despite an unauthenticated context")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
// 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) {
// 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) {
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)
@@ -179,45 +185,60 @@ func TestOwnerOnlyMiddleware_RoleNotFound(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(database, next)
handler := ownerOnlyMiddleware(next)
// Inject user into context as adminAuthMiddleware would.
// User injected, role deliberately absent.
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 missing role")
t.Error("next handler was reached despite no role in context")
}
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403 (role not found)", w.Code)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 (no role in context is unauthenticated)", 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"] != "FORBIDDEN" {
t.Errorf("error = %q, want FORBIDDEN", resp["error"])
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)
}
}
@@ -234,6 +255,10 @@ 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) {
@@ -241,9 +266,11 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(database, next)
handler := ownerOnlyMiddleware(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)
@@ -262,7 +289,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)
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
uid, err := database.CreateUser(context.Background(), "noroleuser", "$2a$12$x", 1)
if err != nil {
@@ -341,7 +368,7 @@ func TestHandleListChannels_DBError(t *testing.T) {
// when the database query fails.
func TestHandleGetSettings_DBError(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := handleGetSettings(database)
handler := handleGetSettings(service.NewSettingsService(database))
_ = database.Close()
@@ -523,16 +550,26 @@ func TestSpawnDetached_CommandConstruction(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) {
// 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) {
database := openWhiteboxTestDB(t)
uid, err := database.CreateUser(context.Background(), "ownerfault", "$2a$12$x", 1)
uid, err := database.CreateUser(context.Background(), "ownerctx", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
@@ -540,7 +577,11 @@ func TestOwnerOnlyMiddleware_RoleLookupFailureIs503(t *testing.T) {
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
}
// Every query against roles now fails with a non-sentinel error.
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.
if _, err := database.ExecContext(context.Background(), `ALTER TABLE roles RENAME TO roles_gone`); err != nil {
t.Fatalf("hide roles: %v", err)
}
@@ -550,24 +591,18 @@ func TestOwnerOnlyMiddleware_RoleLookupFailureIs503(t *testing.T) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(database, next)
handler := ownerOnlyMiddleware(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 reached although the role could not be read")
if !reached {
t.Error("next handler was not reached: the owner gate performed a role lookup instead of consuming the context role")
}
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"])
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 with no role read", w.Code)
}
}
+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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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)
handler := NewAdminAPI(database, "1.0.0", nil, 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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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),
newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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),
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database),
admin.SetupOptions{
ConfigPath: cfgPath,
RunningCfg: wizardRunningCfg(),
-16
View File
@@ -26,22 +26,6 @@ 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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(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))
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
+4 -1
View File
@@ -56,7 +56,10 @@ func fullRouter(t *testing.T) http.Handler {
GIF: config.GIFConfig{APIKey: "absence-test"},
}
rt := app.StartRuntime(cfg, database, nil)
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
return handler
+8 -2
View File
@@ -36,7 +36,10 @@ func setupDiagnosticsRouter(t *testing.T) (http.Handler, string, *db.DB) {
},
}
rt := app.StartRuntime(cfg, database, nil)
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
handler, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil, rt)
t.Cleanup(cleanup)
@@ -108,7 +111,10 @@ func TestDiagnosticsConnectivity_HonoursTrustedProxies(t *testing.T) {
},
}
rt := app.StartRuntime(cfg, database, nil)
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
handler, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil, rt)
t.Cleanup(cleanup)
+24 -3
View File
@@ -15,7 +15,10 @@ 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"
)
@@ -238,7 +241,6 @@ 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",
@@ -247,7 +249,26 @@ func hubWithLiveKit(t *testing.T, status int) *ws.Hub {
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
hub.SetLiveKit(lk)
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)
}
return hub
}
@@ -303,7 +324,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(ws.NewHub(nil, nil, nil))
handler := api.LiveKitHealthHandlerForTest(newBareHub(t, 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,
adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, svc.Roles, svc.Settings,
admin.SetupOptions{ConfigPath: config.DefaultPath, RunningCfg: cfg})
r.Group(func(r chi.Router) {
r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies))
@@ -98,7 +98,10 @@ func TestNewRouter_DeleteAccount_BroadcastsMemberBanOverWS(t *testing.T) {
},
}
rt := app.StartRuntime(cfg, database, nil)
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
+4 -1
View File
@@ -73,7 +73,10 @@ func TestNewRouter_LiveKitProcessStartFailure_VoiceJoinFailsClosed(t *testing.T)
},
}
rt := app.StartRuntime(cfg, database, nil)
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
+4 -1
View File
@@ -33,7 +33,10 @@ func setupRouter(t *testing.T) http.Handler {
},
}
rt := app.StartRuntime(cfg, database, nil)
rt, rtErr := app.StartRuntime(cfg, database, nil)
if rtErr != nil {
t.Fatalf("app.StartRuntime: %v", rtErr)
}
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
return handler
+7 -1
View File
@@ -43,6 +43,12 @@ 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() {
@@ -50,7 +56,7 @@ func TestNewRouterRefusesToStartWithMalformedTOTPKey(t *testing.T) {
panicked = true
}
}()
api.NewRouter(cfg, database, "test", nil, nil, app.StartRuntime(cfg, database, nil))
api.NewRouter(cfg, database, "test", nil, nil, rt)
}()
if !panicked {
+4 -1
View File
@@ -256,7 +256,10 @@ 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 := app.StartRuntime(cfg, database, nil)
rt, err := app.StartRuntime(cfg, database, nil)
if err != nil {
return fmt.Errorf("building runtime for the route walk: %w", err)
}
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": 67.8,
"service": 69.2,
"ws": 86.7
}
}
+26
View File
@@ -5,8 +5,10 @@ import (
"database/sql"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"github.com/J3vb/OwnCord/Server/db/dbgen"
@@ -323,6 +325,30 @@ 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
@@ -0,0 +1,58 @@
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")
}
}
+64 -54
View File
@@ -1,6 +1,7 @@
package app
import (
"fmt"
"log/slog"
"net/url"
@@ -13,16 +14,18 @@ import (
"github.com/J3vb/OwnCord/Server/ws"
)
// StartRuntime builds the collaborators the hub and the router share, applies
// every pre-Run hub setter, and starts the hub's dispatch goroutine.
// 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.
//
// 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.
// 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.
//
// 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
@@ -32,75 +35,82 @@ 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 {
func StartRuntime(cfg *config.Config, database *db.DB, pluginRegistry *plugin.Registry) (api.Runtime, error) {
// 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 := ws.NewHub(database, limiter, svc)
// Replay budget knobs must land before hub.Run starts (below).
hub.ConfigureReplay(cfg.EventPersistence.ReplayRingSize, cfg.EventPersistence.ReplayColdLimit)
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)
}
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.
// The plugin event sink consumes the built hub's broadcaster, so it is
// the surviving two-phase wire (moved from api.routerPluginWiring).
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
}
// 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
// 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
// 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 startVoice(cfg *config.Config, hub *ws.Hub) bool {
func buildVoice(cfg *config.Config) (*ws.LiveKitClient, *ws.LiveKitProcess, 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 false
return nil, nil, false
}
hub.SetLiveKit(lk)
// Optionally start a companion LiveKit process — either from a
// Optionally build a companion LiveKit process — either from a
// configured binary or via checksum-verified auto-download (the
// download happens in the background inside Start).
// 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.
if cfg.Voice.LiveKitBinaryPath != "" || cfg.Voice.AutoDownloadLiveKit {
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
return lk, ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir), true
}
// Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs.
@@ -113,5 +123,5 @@ func startVoice(cfg *config.Config, hub *ws.Hub) bool {
"add the LiveKit server's IP to livekit_webhook_allowed_cidrs or webhooks will be silently dropped",
"livekit_host", lkHost)
}
return true
return lk, nil, true
}
+11 -5
View File
@@ -198,9 +198,11 @@ func (a *App) startPlugins() error {
return nil
}
// 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.
// 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.
//
// Its close step is GracefulStopContext, the only caller of
// LiveKitProcess.Stop and what closes the dispatch goroutine. gracefulOnce
@@ -208,7 +210,11 @@ 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 {
a.runtime = StartRuntime(a.cfg, a.database, a.plugins)
rt, err := StartRuntime(a.cfg, a.database, a.plugins)
if err != nil {
return err
}
a.runtime = rt
a.hub = a.runtime.Hub
a.onClose("hub", func(ctx context.Context) error {
a.runtime.Hub.GracefulStopContext(ctx)
@@ -260,7 +266,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)
stop := startMaintenanceLoop(a.bgCtx, a.log, a.cfg, a.database, a.runtime.Services.Settings)
a.onClose("maintenance", func(context.Context) error {
stop()
return nil
+13 -3
View File
@@ -18,6 +18,7 @@ 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"
)
@@ -115,7 +116,10 @@ func TestSeedHubReplayState_ForcesFullResyncForOfflineClient(t *testing.T) {
}
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter, nil)
hub, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
go hub.Run()
defer hub.Stop()
@@ -260,7 +264,10 @@ 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 := ws.NewHub(database, limiter, nil)
hubOld, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
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)
@@ -275,7 +282,10 @@ 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 := ws.NewHub(database, limiter, nil)
hubNew, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
go hubNew.Run()
defer hubNew.Stop()
if persister, prunerDone := startEventPersister(ctx, log, cfg, hubNew, database); persister != nil || prunerDone != nil {
+7 -6
View File
@@ -8,12 +8,13 @@ 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) func() {
func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.Config, database *db.DB, settings *service.SettingsService) func() {
// Periodically purge expired sessions and orphaned attachments.
fileStorage, fileStorageErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB)
if fileStorageErr != nil {
@@ -22,7 +23,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, stopMaintenance, maintenanceDone)
go maintenanceLoop(bgCtx, log, database, fileStorage, settings, stopMaintenance, maintenanceDone)
return func() {
// Backstop for early returns below (see hub.GracefulStop defer above),
@@ -40,7 +41,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, stopMaintenance, maintenanceDone chan struct{}) {
func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage, settings *service.SettingsService, stopMaintenance, maintenanceDone chan struct{}) {
defer close(maintenanceDone)
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
@@ -57,7 +58,7 @@ func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, f
continue
}
if maintenanceTick(bgCtx, log, database, fileStorage) {
if maintenanceTick(bgCtx, log, database, fileStorage, settings) {
consecutiveFailures++
} else {
consecutiveFailures = 0
@@ -70,7 +71,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) bool {
func maintenanceTick(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage, settings *service.SettingsService) bool {
tickFailed := false
if err := database.DeleteExpiredSessions(bgCtx); err != nil {
log.Warn("failed to delete expired sessions", "error", err)
@@ -79,7 +80,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); err != nil {
if err := admin.MaintainBackups(bgCtx, database, settings); err != nil {
log.Warn("backup maintenance failed", "error", err)
tickFailed = true
}
+10 -7
View File
@@ -41,12 +41,11 @@ 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": {"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/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/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"},
@@ -91,14 +90,18 @@ 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": {"move", "settings-ops", "GetSetting at construction"},
"ws/hub_broadcast.go": {"move", "channel", "visibility refresh reads channels, roles, users"},
"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_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", "session lookup on the WebSocket handshake"},
"ws/serve_auth.go": {"move", "auth", "handshake auth: session, user and role lookups, connect audit, failed-handshake teardown"},
"ws/serve_pumps.go": {"move", "user", "MarkUserDisconnected on pump exit"},
"ws/serve_ready.go": {"move", "channel", "ready snapshot: channels, overrides, unreads, DMs, members"},
"ws/serve_ready.go": {"move", "channel", "ready snapshot and fresh-connect: channels, overrides, unreads, DMs, members, stale-voice cleanup"},
"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,4 +201,7 @@ 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,6 +22,7 @@ type Services struct {
Moderation *ModerationService
Roles *RoleService
Emoji *EmojiService
Settings *SettingsService
}
// New creates all domain services wired together.
@@ -39,5 +40,6 @@ 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
@@ -0,0 +1,175 @@
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
@@ -0,0 +1,189 @@
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,7 +16,6 @@ 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 {
@@ -51,7 +50,7 @@ func sortedKeys(m map[int64]bool) []int64 {
func TestChannelVisibility_RESTWSAgreement(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter, nil)
hub := newTestHubDeps(t, database, limiter, nil)
svc := service.New(database, limiter)
// Seed one channel of each server type plus a dm channel (never visible).
@@ -177,7 +176,7 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) {
func TestChannelVisibility_UserOverrideAgreement(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter, nil)
hub := newTestHubDeps(t, database, limiter, nil)
svc := service.New(database, limiter)
openID, err := database.CreateChannel(context.Background(), "open", "text", "", "", 0)
+2 -3
View File
@@ -82,9 +82,8 @@ 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)
// Inject a test LiveKit client so voice_join passes the livekit!=nil guard.
// 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",
@@ -93,7 +92,7 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) {
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
hub.SetLiveKit(lk)
hub := newTestHubWith(t, ws.HubOptions{DB: database, Limiter: limiter, Services: svc, LiveKit: 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 := ws.NewHub(database, limiter, svc)
hub := newTestHubDeps(t, database, limiter, svc)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
+15 -18
View File
@@ -12,15 +12,14 @@ import (
"github.com/J3vb/OwnCord/Server/ws"
)
// compile-time check that SetPluginRegistry is exported.
var _ = (*ws.Hub)(nil)
// The registry arrives via HubOptions since B3-4; these tests wire it at
// construction.
// ─── chat_command dispatch via HandleMessageForTest ───────────────────────────
// TestChatCommand_NoRegistry returns an error when no plugin registry is wired.
func TestChatCommand_NoRegistry_ReturnsError(t *testing.T) {
hub, database := newTestHub(t)
_ = database
hub, _ := newTestHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
@@ -53,17 +52,16 @@ 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) {
hub, database := newTestHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
database := openTestDB(t)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub.SetPluginRegistry(reg)
hub := newTestHubWith(t, ws.HubOptions{DB: database, PluginRegistry: reg})
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
raw, _ := json.Marshal(map[string]any{
"type": "chat_command",
@@ -117,17 +115,16 @@ 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) {
hub, database := newTestHub(t)
send := make(chan []byte, 32)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
database := openTestDB(t)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub.SetPluginRegistry(reg)
hub := newTestHubWith(t, ws.HubOptions{DB: database, PluginRegistry: reg})
send := make(chan []byte, 32)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
sawRateLimited := false
for i := range 20 {
+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 := ws.NewHub(database, limiter, svc)
hub := newTestHubDeps(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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",
+11 -465
View File
@@ -4,7 +4,6 @@ package ws
import (
"context"
"log/slog"
"os"
"sync"
"sync/atomic"
"time"
@@ -60,17 +59,18 @@ 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 main.go wires these after NewRouter has already started the
// Run loop, which reads them on the broadcast/replay paths.
// because internal/app wires these one lifecycle stage after Run has
// started, 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; wire before Run
pluginRegistry *plugin.Registry // slash-command dispatch; nil = no plugins; HubOptions field (B3-4)
pluginSink atomic.Pointer[plugin.EventSink] // hub→plugin event fan-out; nil = no plugins
// running flips when Run starts; plain-field setters check it so a late
// call fails loudly instead of racing the dispatch loop.
// 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 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). Set via ConfigureReplay before Run.
// compiled-in default (maxColdReplay). HubOptions.ReplayColdLimit (B3-4).
coldReplayLimit int
// In-flight guards for the DB-heavy sweeps Run kicks off in their own
@@ -119,6 +119,10 @@ 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
@@ -137,133 +141,6 @@ 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.
//
@@ -412,37 +289,6 @@ 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 {
@@ -460,16 +306,6 @@ 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 {
@@ -477,246 +313,6 @@ 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()
@@ -752,30 +348,6 @@ 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) {
@@ -787,32 +359,6 @@ 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,7 +6,6 @@ import (
"time"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/telemetry"
)
@@ -80,69 +79,6 @@ 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
@@ -173,138 +109,6 @@ 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.
@@ -354,167 +158,6 @@ 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.
//
@@ -597,151 +240,6 @@ 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) {
@@ -770,112 +268,6 @@ 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 {
+5 -11
View File
@@ -53,20 +53,14 @@ 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)
}
+5 -19
View File
@@ -5,18 +5,9 @@ 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. Satisfies VoiceTokenGenerator so the Hub can be passed
// as a dep at registration time (before SetLiveKit is called).
// is not configured (HubOptions.LiveKit was nil). Satisfies
// VoiceTokenGenerator so the Hub can be passed as a dep at registration time.
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")
@@ -43,11 +34,6 @@ func (h *Hub) LiveKitHealthCheck(ctx context.Context) (bool, error) {
return h.livekit.HealthCheck(ctx)
}
// 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
}
// 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.
+185
View File
@@ -0,0 +1,185 @@
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
@@ -0,0 +1,153 @@
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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
user, err := database.GetUserByID(ctx, uid)
if err != nil || user == nil {
+253
View File
@@ -0,0 +1,253 @@
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
@@ -0,0 +1,50 @@
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) {
hub, database := newTestHub(t)
hub.ConfigureReplay(simRing, 0)
database := openTestDB(t)
hub := newTestHubWith(t, ws.HubOptions{DB: database, ReplayRingSize: simRing})
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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
c := NewTestClient(h, uid, make(chan []byte, 8))
h.clients[uid] = c
c.setVoiceState(chA, "tok-a")
+43 -10
View File
@@ -12,6 +12,7 @@ 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"
)
@@ -38,7 +39,7 @@ func newTestHub(t testing.TB) (*ws.Hub, *db.DB) {
t.Helper()
database := openTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter, nil)
hub := newTestHubDeps(t, database, limiter, nil)
return hub, database
}
@@ -669,10 +670,34 @@ func assertNotReceived(t *testing.T, ch <-chan []byte, label string) {
// ─── LiveKit lifecycle ────────────────────────────────────────────────────────
func TestHub_SetLiveKit_NilSafe(t *testing.T) {
// 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) {
hub, _ := newTestHub(t)
// Setting a nil LiveKit client must not panic.
hub.SetLiveKit(nil)
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")
}
}
// ─── GracefulStop ─────────────────────────────────────────────────────────────
@@ -944,13 +969,21 @@ func TestHub_LiveKitHealthCheck_NilReturnsError(t *testing.T) {
}
}
// ─── SetLiveKitProcess ──────────────────────────────────────────────────────
// ─── LiveKitProcess option ──────────────────────────────────────────────────
func TestHub_SetLiveKitProcess(t *testing.T) {
hub, _ := newTestHub(t)
hub.SetLiveKitProcess(nil)
go hub.Run()
hub.GracefulStop()
// 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")
}
}
// ─── VoiceSessionCount ─────────────────────────────────────────────────────
+487
View File
@@ -0,0 +1,487 @@
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 := ws.NewHub(database, limiter, svc)
hub := newTestHubDeps(t, database, limiter, svc)
runDone := make(chan struct{})
go func() {
@@ -30,7 +30,6 @@ import (
"testing"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
)
@@ -55,8 +54,7 @@ func TestVoiceJoin_GetChannelVoiceStatesError_RollsBackAndNotifiesClient(t *test
t.Fatalf("NewLiveKitClient: %v", err)
}
h := NewHub(database, auth.NewRateLimiter(), nil)
h.SetLiveKit(lk)
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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,7 +26,6 @@ import (
"testing"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
)
@@ -49,8 +48,7 @@ func TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic(t *testing.
t.Fatalf("NewLiveKitClient: %v", err)
}
h := NewHub(database, auth.NewRateLimiter(), nil)
h.SetLiveKit(lk)
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
c := newClient(hub, nil, user, "", 0, ctx)
// The ban commits AFTER authenticateConn passed (c.user is still the
@@ -23,7 +23,6 @@ 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"
@@ -51,8 +50,7 @@ func TestRollbackVoiceJoin_BroadcastReachesLeaverWithoutReadAccess(t *testing.T)
t.Fatalf("NewLiveKitClient: %v", err)
}
h := NewHub(database, auth.NewRateLimiter(), nil)
h.SetLiveKit(lk)
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
const (
chanID = int64(500)
@@ -43,7 +43,7 @@ func TestRefreshUserSnapshot_FailsClosedWhenNewRoleLookupFails(t *testing.T) {
t.Fatalf("GetUserByID: %v", err)
}
hub := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := ws.NewHub(database, limiter, svc)
hub := newTestHubDeps(t, 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 := ws.NewHub(database, limiter, svc)
hub := newTestHubDeps(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
user, err := database.GetUserByID(ctx, uid)
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
+4 -2
View File
@@ -357,7 +357,6 @@ 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.
@@ -369,7 +368,10 @@ func newEpochRig(t *testing.T, journey string) *epochRig {
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
hub.SetLiveKit(lk)
hub := newTestHubWith(t, ws.HubOptions{
DB: database, Limiter: limiter,
Services: service.New(database, limiter), LiveKit: 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := ws.NewHub(database, limiter, nil)
hub := newTestHubDeps(t, 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 := ws.NewHub(database, limiter, nil)
hub := newTestHubDeps(t, database, limiter, nil)
hub.SetEventStore(eventStore)
go hub.Run()
defer hub.Stop()
@@ -304,7 +304,7 @@ func TestReconnect_ColdTierMergesRingBufferTail(t *testing.T) {
}
}
hub := ws.NewHub(database, limiter, nil)
hub := newTestHubDeps(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := ws.NewHub(database, limiter, nil)
hub := newTestHubDeps(t, 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 := ws.NewHub(database, limiter, nil)
hub := newTestHubDeps(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
// Precondition: the channel starts out READ-visible to this user.
allowedBefore, err := h.computeAllowedChannels(ctx, database, user)
@@ -25,8 +25,6 @@ import (
"context"
"fmt"
"testing"
"github.com/J3vb/OwnCord/Server/auth"
)
func TestLiveVoiceEventsSince_ColdTierCapHit_DegradesToNil(t *testing.T) {
@@ -49,9 +47,8 @@ func TestLiveVoiceEventsSince_ColdTierCapHit_DegradesToNil(t *testing.T) {
}
}
hub := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHubWith(t, HubOptions{DB: database, ReplayColdLimit: coldCap})
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.
@@ -85,9 +82,8 @@ func TestLiveVoiceEventsSince_ColdTierExactCap_ReturnsCompleteWindow(t *testing.
}
}
hub := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHubWith(t, HubOptions{DB: database, ReplayColdLimit: coldCap})
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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
+496
View File
@@ -0,0 +1,496 @@
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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
@@ -277,7 +277,7 @@ func TestFreshConnectFallback_RoleReassignPreRegister_PostRegisterVerifyRevokes(
t.Fatalf("CreateSession: %v", err)
}
hub := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, 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 := NewHub(database, auth.NewRateLimiter(), nil)
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
c1 := newClient(hub, nil, user, "", 0, ctx)
c2 := newClient(hub, nil, user, "", 0, ctx)
-807
View File
@@ -6,7 +6,6 @@ import (
"log/slog"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/coder/websocket"
@@ -14,8 +13,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/permissions"
"github.com/J3vb/OwnCord/Server/telemetry"
)
const (
@@ -26,31 +23,8 @@ 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.
@@ -119,45 +93,6 @@ 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
@@ -175,179 +110,6 @@ 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
@@ -386,342 +148,6 @@ 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.
//
@@ -755,236 +181,3 @@ 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,6 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/coder/websocket"
@@ -103,3 +106,102 @@ 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