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
130 changed files with 4919 additions and 2790 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"
}
}
]
}
+4 -1
View File
@@ -11,7 +11,10 @@ prometheus.
- `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`)
- `cmd/` executable tooling, one `package main` per subdirectory —
`cmd/genprotocol/` regenerates the protocol constants from `protocol/schema.json`,
`cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`),
`cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`; add
`-profile alpha` for the deterministic B3-7 dataset behind
`Server/testdata/snapshots/v1.2.0-alpha.4.sqlite` — regenerate that snapshot
only deliberately, per its README),
`cmd/dbinventory/` prints the `db`-importer table for
`docs/architecture/server-boundaries.md` (exits 1 on an unlisted importer).
`cmd/gendocs/` rewrites the route, table and config-key index blocks in
+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()
+13 -1
View File
@@ -129,6 +129,9 @@ var seedDMMessages = []struct {
func main() {
dbPath := flag.String("db", "data/chatserver.db", "path to the SQLite database file")
confirmDev := flag.Bool("confirm-dev", false, "confirm this is a development database (required)")
profile := flag.String("profile", "dev", `seed profile: "dev" (small, idempotent) or "alpha" (deterministic alpha-shaped dataset; empty database only)`)
snapshotPath := flag.String("snapshot", "", "alpha profile only: scrub and VACUUM INTO this path after seeding")
scrubPath := flag.String("scrub", "", "alpha profile only: SQL scrub script applied before -snapshot (testdata/snapshots/scrub.sql)")
flag.Parse()
if !*confirmDev {
@@ -153,7 +156,16 @@ func main() {
log.Fatalf("failed to open database at %s: %v", *dbPath, err)
}
exitCode := run(database)
var exitCode int
switch *profile {
case "dev":
exitCode = run(database)
case "alpha":
exitCode = runAlpha(database, *snapshotPath, *scrubPath)
default:
log.Printf("unknown -profile %q (want dev or alpha)", *profile)
exitCode = 2
}
_ = database.Close()
os.Exit(exitCode)
}
+769
View File
@@ -0,0 +1,769 @@
// The alpha-shaped profile (B3-7, roadmap workstream 12).
//
// `go run ./cmd/seed -confirm-dev -profile alpha` fills an EMPTY database with
// a deterministic dataset shaped like a real 1.2.0-alpha.4 server after a
// month of use. The same profile, scrubbed and vacuumed, is the committed
// snapshot at Server/testdata/snapshots/v1.2.0-alpha.4.sqlite that B4's HP-4
// drills, B6's upgrade rehearsal and B10's in-place upgrade consume — the
// schema has not changed since the v1.2.0-alpha.4 tag (zero migrations added),
// so a database seeded on today's migrations IS an alpha.4-schema database.
//
// Determinism is the contract: fixed rng seed, fixed clock, a constant bcrypt
// hash (live hashing salts every run), explicit ids and timestamps on every
// row, one insert order, and `VACUUM INTO` as the byte-canonical output.
// TestAlphaProfileByteIdentical holds it: two runs, two files, bytes.Equal.
//
// The numbers below are the plan's (b3-server-architecture-guardrails
// §B3-7). A number found unrepresentative is changed HERE with the reason in
// the B3-7 evidence block — never silently. Two constants deliberately leave
// no rows, recorded rather than hidden:
//
// - alphaVoiceSessions: voice is LiveKit-ephemeral. A finished session
// leaves no at-rest row in this schema (voice_states is live presence and
// empties on disconnect), so a cold snapshot of a quiescent server
// carries none. The constant stays because the load harness and any
// future voice-history feature consume the same shape.
// - events: the replay log is empty, as on any server restarted for an
// upgrade — exactly the state the snapshot's consumers rehearse.
// Fabricated frames would be replayed verbatim to real clients.
//
// The seeded accounts all use the password "alpha-dev-password" (constant
// hash below), which is why -confirm-dev stays mandatory.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"math/rand"
"os"
"sort"
"strings"
"time"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
)
// ─── The profile, as constants ──────────────────────────────────────────────
const (
alphaSeed = 20260831 // fixed rng seed; changing it is changing the dataset
alphaUsers = 100 // 1 owner + 2 admins + 5 moderators + 92 members
alphaOwners = 1
alphaAdmins = 2
alphaModerators = 5
alphaChannels = 12 // 10 text + 2 voice; 3 role-override, 2 user-override, 1 archived
alphaMessages = 20000
alphaWindowDays = 30
alphaDMShare = 0.15 // exactly 3,000 of the 20,000 land in DMs
alphaDMPairs = 40
alphaAttachments = 300 // image/audio/video/other 60/10/10/20 %
alphaReactions = 500
alphaInvites = 30 // 10 of them revoked
alphaInvitesRevkd = 10
alphaPluginRows = 1 // disabled
// alphaVoiceSessions is a shape parameter with no at-rest rows — see the
// package comment. 145 min, 26 participants when a live harness uses it.
alphaVoiceSessions = 200
// alphaPasswordHash is bcrypt("alpha-dev-password"), fixed so two seed
// runs produce identical bytes. Weak and public by design: -confirm-dev.
//nolint:gosec // G101: a deliberately public dev-profile hash ("alpha-dev-password"); -confirm-dev gates every use
alphaPasswordHash = "$2a$12$EBrRXmplT1ryU0o/HzELSePreo.gK5.z5Tjo4ec/ISchy5gKwxtQq"
)
// alphaWindowEnd is the fixed clock: the last instant of the simulated month.
var alphaWindowEnd = time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
// alphaHourWeights shapes the diurnal curve — quiet nights, an evening peak.
// Relative weights per UTC hour; largest-remainder allocation makes the
// per-bucket counts sum exactly to the day's total.
var alphaHourWeights = [24]int{
1, 1, 1, 1, 1, 2, // 0005
3, 5, 7, 8, 8, 8, // 0611
9, 9, 8, 8, 9, 10, // 1217
11, 12, 11, 8, 5, 2, // 1823
}
// alphaLexicon feeds the message generator. Plain words only: the content is
// synthetic on purpose and never pretends to be a real person's text.
var alphaLexicon = []string{
"the", "a", "we", "you", "it", "that", "this", "just", "really", "pretty",
"server", "channel", "voice", "call", "message", "update", "build", "bug",
"fix", "release", "game", "night", "stream", "clip", "map", "round",
"team", "later", "today", "tomorrow", "tonight", "morning", "again",
"works", "broke", "fixed", "joined", "left", "restarted", "uploaded",
"good", "great", "weird", "slow", "fast", "new", "old", "same",
"anyone", "someone", "everyone", "nobody", "who", "what", "when", "why",
"up", "down", "in", "out", "on", "off", "for", "with", "about", "after",
"lol", "nice", "thanks", "ok", "sure", "maybe", "yes", "no", "brb", "gg",
}
// ─── Channel definitions ────────────────────────────────────────────────────
type alphaChannel struct {
name, ctype, category, topic string
position int
archived bool
}
// Ten text and two voice channels. Indices are ids-1; the overrides below
// refer to them by id. Channel 10 is the archived one and only carries
// early-window history.
var alphaChannelDefs = []alphaChannel{
{"welcome", "text", "Information", "Start here — rules and invites", 0, false},
{"announcements", "text", "Information", "Server news; staff post, everyone reads", 1, false},
{"general", "text", "Text Channels", "General chat", 2, false},
{"random", "text", "Text Channels", "Off-topic", 3, false},
{"gaming", "text", "Text Channels", "What are we playing", 4, false},
{"media", "text", "Text Channels", "Clips, screenshots, links", 5, false},
{"dev", "text", "Text Channels", "Server tinkering and self-hosting", 6, false},
{"support", "text", "Text Channels", "Ask for help here", 7, false},
{"staff", "text", "Staff", "Staff-only coordination", 8, false},
{"archive-2026-06", "text", "Archive", "June history, read-only", 9, true},
{"Lounge", "voice", "Voice", "", 10, false},
{"Game Night", "voice", "Voice", "", 11, false},
}
// ─── Entry point ────────────────────────────────────────────────────────────
// runAlpha is the -profile alpha path: migrate, seed, optionally scrub and
// write the byte-canonical snapshot.
func runAlpha(database *db.DB, snapshotPath, scrubPath string) int {
if err := db.Migrate(database); err != nil {
log.Printf("failed to run migrations: %v", err)
return 1
}
if err := seedAlpha(database); err != nil {
log.Printf("%v", err)
return 1
}
fmt.Println("--- Alpha profile seeded ---")
fmt.Printf(" Users: %d\n", alphaUsers)
fmt.Printf(" Channels: %d (+%d DM channels)\n", alphaChannels, alphaDMPairs)
fmt.Printf(" Messages: %d over %d days (%.0f%% in DMs)\n", alphaMessages, alphaWindowDays, alphaDMShare*100)
fmt.Printf(" Attachments: %d · Reactions: %d · Invites: %d (%d revoked) · Plugins: %d\n",
alphaAttachments, alphaReactions, alphaInvites, alphaInvitesRevkd, alphaPluginRows)
fmt.Printf(" Voice sessions: %d — a shape parameter; a finished session leaves no at-rest row (package comment)\n", alphaVoiceSessions)
fmt.Println(" Password for every account: alpha-dev-password")
if snapshotPath != "" {
if scrubPath == "" {
log.Printf("-snapshot requires -scrub (the committed scrub script)")
return 1
}
if err := writeSnapshot(database, scrubPath, snapshotPath); err != nil {
log.Printf("%v", err)
return 1
}
fmt.Printf(" Snapshot: %s\n", snapshotPath)
}
return 0
}
// seedAlpha fills an empty database with the profile. It refuses a non-empty
// one: determinism is per-database-lifetime, and "empty" is the only state
// two runs can share.
func seedAlpha(database *db.DB) error {
var users int
if err := database.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM users`).Scan(&users); err != nil {
return fmt.Errorf("alpha: counting users: %w", err)
}
if users > 0 {
return fmt.Errorf("alpha: the profile seeds only an empty database (found %d users); point -db at a fresh file", users)
}
//nolint:gosec // G404: byte-for-byte determinism is this profile's contract; nothing here is cryptographic
rng := rand.New(rand.NewSource(alphaSeed))
windowStart := alphaWindowEnd.AddDate(0, 0, -alphaWindowDays)
joined := alphaJoinTimes(rng, windowStart)
tx, err := database.BeginTx(context.Background(), &sql.TxOptions{})
if err != nil {
return fmt.Errorf("alpha: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := alphaInsertUsers(tx, rng, joined); err != nil {
return err
}
if err := alphaInsertChannels(tx, windowStart); err != nil {
return err
}
pairs, err := alphaInsertDMs(tx, rng, joined)
if err != nil {
return err
}
messageTimes, messageAuthors, err := alphaInsertMessages(tx, rng, windowStart, joined, pairs)
if err != nil {
return err
}
if err := alphaInsertAttachments(tx, rng, messageTimes, messageAuthors); err != nil {
return err
}
if err := alphaInsertReactions(tx, rng); err != nil {
return err
}
if err := alphaInsertInvites(tx, rng, windowStart); err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO plugins (id, name, version, enabled, manifest_json, installed_at) VALUES (1, ?, ?, 0, ?, ?)`,
"example-echo", "0.1.0",
`{"name":"example-echo","version":"0.1.0","description":"seed profile placeholder; never enabled"}`,
ts(windowStart.Add(24*time.Hour)),
); err != nil {
return fmt.Errorf("alpha: plugin row: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("alpha: commit: %w", err)
}
return nil
}
// writeSnapshot applies the scrub script and writes the byte-canonical
// snapshot via VACUUM INTO (which refuses to overwrite; remove a stale
// target first so regeneration is one command).
func writeSnapshot(database *db.DB, scrubPath, outPath string) error {
scrub, err := os.ReadFile(scrubPath)
if err != nil {
return fmt.Errorf("alpha: reading scrub script: %w", err)
}
// Strip comment lines first, then split on ";" — a ";" inside a comment
// must not end a statement. The scrub script holds no string literal
// containing ";", which is the simplicity this parser is allowed.
var sqlOnly []string
for line := range strings.SplitSeq(string(scrub), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "--") {
continue
}
sqlOnly = append(sqlOnly, line)
}
for stmt := range strings.SplitSeq(strings.Join(sqlOnly, "\n"), ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := database.ExecContext(context.Background(), stmt); err != nil {
return fmt.Errorf("alpha: scrub statement %q: %w", stmt[:min(40, len(stmt))], err)
}
}
if err := os.Remove(outPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("alpha: removing stale snapshot: %w", err)
}
quoted := strings.ReplaceAll(outPath, "'", "''")
if _, err := database.ExecContext(context.Background(), "VACUUM INTO '"+quoted+"'"); err != nil {
return fmt.Errorf("alpha: VACUUM INTO %s: %w", outPath, err)
}
return nil
}
// ─── Users ──────────────────────────────────────────────────────────────────
// alphaJoinTimes computes every user's created_at once — the message and DM
// generators consult it so nothing is authored before its author joined (a
// fixture with time-travelling rows would poison the retention and
// account-age rehearsals that consume it; Codex on #1469, P2). Monotonic in
// the user id — the first forty accounts predate the window (an established
// server; all staff are among them), the remaining sixty join during it in
// id order — so "who exists at time t" is always the prefix 1..N.
func alphaJoinTimes(rng *rand.Rand, windowStart time.Time) []time.Time {
joined := make([]time.Time, alphaUsers+1)
for i := 1; i <= alphaUsers; i++ {
if i <= 40 {
joined[i] = windowStart.AddDate(0, 0, -60).Add(time.Duration(i) * 26 * time.Hour)
continue
}
// Spacing is 12h, jitter under 1h: monotonicity holds by construction.
into := time.Duration(i-41) * (time.Duration(alphaWindowDays) * 24 * time.Hour) / 60
joined[i] = windowStart.Add(into).Add(time.Duration(rng.Intn(3599)) * time.Second)
}
return joined
}
func alphaInsertUsers(tx *sql.Tx, rng *rand.Rand, joined []time.Time) error {
roleOf := func(i int) int { // i is 1-based user number
switch {
case i <= alphaOwners:
return 1
case i <= alphaOwners+alphaAdmins:
return 2
case i <= alphaOwners+alphaAdmins+alphaModerators:
return 3
default:
return 4
}
}
const cols = 7
var b strings.Builder
args := make([]any, 0, alphaUsers*cols)
for i := 1; i <= alphaUsers; i++ {
if i > 1 {
b.WriteString(",")
}
b.WriteString("(?,?,?,?,?,?,?)")
created := joined[i]
lastSeen := alphaWindowEnd.Add(-time.Duration(rng.Intn(72*3600)) * time.Second)
if lastSeen.Before(created) {
// A user who joined in the window's final days was last seen at
// the join, not before it.
lastSeen = created
}
args = append(args,
i, fmt.Sprintf("user%03d", i), alphaPasswordHash, roleOf(i),
"offline", ts(created), ts(lastSeen),
)
}
_, err := tx.Exec(
//nolint:gosec // G202: the concatenation is a builder of "(?,…)" placeholder groups; every value is bound
`INSERT INTO users (id, username, password, role_id, status, created_at, last_seen) VALUES `+b.String(),
args...,
)
if err != nil {
return fmt.Errorf("alpha: users: %w", err)
}
return nil
}
// ─── Channels and overrides ─────────────────────────────────────────────────
func alphaInsertChannels(tx *sql.Tx, windowStart time.Time) error {
created := windowStart.AddDate(0, 0, -60)
for i, c := range alphaChannelDefs {
archived := 0
if c.archived {
archived = 1
}
if _, err := tx.Exec(
`INSERT INTO channels (id, name, type, category, topic, position, archived, is_group, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`,
i+1, c.name, c.ctype, c.category, c.topic, c.position, archived, ts(created.Add(time.Duration(i)*time.Minute)),
); err != nil {
return fmt.Errorf("alpha: channel %q: %w", c.name, err)
}
}
// Three channels carry role overrides (ids 1, 2, 9), two carry user
// overrides (ids 9 and 4) — the plan's 3/2 split, with the layered case
// (channel 9 has both) included deliberately.
roleOverrides := []struct {
channel, role int64
allow, deny int64
}{
{1, 4, 0, permissions.SendMessages}, // #welcome: members read-only
{2, 4, 0, permissions.SendMessages}, // #announcements: members read-only
{2, 3, permissions.MentionEveryone, 0}, // …and moderators may @everyone there
{9, 4, 0, permissions.ReadMessages | permissions.SendMessages}, // #staff hidden from members
}
for _, o := range roleOverrides {
if _, err := tx.Exec(
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`,
o.channel, o.role, o.allow, o.deny,
); err != nil {
return fmt.Errorf("alpha: role override ch=%d role=%d: %w", o.channel, o.role, err)
}
}
userOverrides := []struct {
channel, user int64
allow, deny int64
}{
{9, 9, permissions.ReadMessages | permissions.SendMessages, 0}, // a trusted member sees #staff
{4, 42, 0, permissions.SendMessages}, // user042 is channel-muted in #random
}
for _, o := range userOverrides {
if _, err := tx.Exec(
`INSERT INTO channel_user_overrides (channel_id, user_id, allow, deny) VALUES (?, ?, ?, ?)`,
o.channel, o.user, o.allow, o.deny,
); err != nil {
return fmt.Errorf("alpha: user override ch=%d user=%d: %w", o.channel, o.user, err)
}
}
return nil
}
// ─── DMs ────────────────────────────────────────────────────────────────────
type dmPair struct {
channelID int64
a, b int64
// ready is when both members exist — no message in the pair may precede
// it, and it is the channel's created_at / opened_at, matching
// GetOrCreateDMChannel's create-on-first-contact shape.
ready time.Time
}
// alphaInsertDMs creates the 40 DM channels exactly as GetOrCreateDMChannel
// would (type 'dm', empty name, both participants, both open). The first
// twelve pairs are drawn from the pre-window accounts so DM traffic exists
// from the window's first hour; the rest may involve joiners and only carry
// messages once both members exist.
func alphaInsertDMs(tx *sql.Tx, rng *rand.Rand, joined []time.Time) ([]dmPair, error) {
seen := map[[2]int64]bool{}
pairs := make([]dmPair, 0, alphaDMPairs)
for len(pairs) < alphaDMPairs {
pool := alphaUsers
if len(pairs) < 12 {
pool = 40 // both members pre-window
}
a := int64(rng.Intn(pool) + 1)
b := int64(rng.Intn(pool) + 1)
if a == b {
continue
}
if a > b {
a, b = b, a
}
if seen[[2]int64{a, b}] {
continue
}
seen[[2]int64{a, b}] = true
id := int64(alphaChannels + len(pairs) + 1) // 13..52
ready := joined[a]
if joined[b].After(ready) {
ready = joined[b]
}
if _, err := tx.Exec(
`INSERT INTO channels (id, name, type, is_group, created_at) VALUES (?, '', 'dm', 0, ?)`,
id, ts(ready),
); err != nil {
return nil, fmt.Errorf("alpha: dm channel %d: %w", id, err)
}
for _, u := range []int64{a, b} {
if _, err := tx.Exec(
`INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, id, u,
); err != nil {
return nil, fmt.Errorf("alpha: dm participant: %w", err)
}
if _, err := tx.Exec(
`INSERT INTO dm_open_state (user_id, channel_id, opened_at) VALUES (?, ?, ?)`, u, id, ts(ready),
); err != nil {
return nil, fmt.Errorf("alpha: dm open state: %w", err)
}
}
pairs = append(pairs, dmPair{channelID: id, a: a, b: b, ready: ready})
}
return pairs, nil
}
// ─── Messages ───────────────────────────────────────────────────────────────
// alphaChannelWeights is the relative traffic per text channel (ids 1..10):
// #announcements and #welcome are quiet, #general and #gaming busy, #staff
// small, the archive early-only.
var alphaChannelWeights = []int{2, 1, 30, 18, 22, 10, 8, 6, 3, 8}
// alphaChannelFor picks a text channel for one message on the given day; the
// archived channel's history ends on day 10.
func alphaChannelFor(rng *rand.Rand, day int) int64 {
w := make([]int, len(alphaChannelWeights))
copy(w, alphaChannelWeights)
if day >= 10 {
w[9] = 0
}
total := 0
for _, x := range w {
total += x
}
n := rng.Intn(total)
for i, x := range w {
if n < x {
return int64(i + 1)
}
}
return 3
}
// alphaAuthorFor picks a message author. Staff channels (#welcome,
// #announcements) are staff-posted; #staff adds the override-listed user009;
// everywhere else, anyone who has joined by the message's time — maxUser is
// the eligibility prefix (staff and user009 are all pre-window).
func alphaAuthorFor(rng *rand.Rand, ch int64, maxUser int) int64 {
switch ch {
case 1, 2:
return int64(rng.Intn(alphaOwners+alphaAdmins+alphaModerators) + 1)
case 9:
staff := []int64{1, 2, 3, 4, 5, 6, 7, 8, 9}
return staff[rng.Intn(len(staff))]
default:
return int64(rng.Intn(maxUser) + 1)
}
}
// sqlBatch accumulates multi-row VALUES groups and flushes them in chunks —
// one statement per chunk instead of one per row, inside the caller's
// transaction.
type sqlBatch struct {
b strings.Builder
args []any
}
func (sb *sqlBatch) add(row string, vals ...any) {
if len(sb.args) > 0 {
sb.b.WriteString(",")
}
sb.b.WriteString(row)
sb.args = append(sb.args, vals...)
}
func (sb *sqlBatch) flush(tx *sql.Tx, prefix string) error {
if sb.b.Len() == 0 {
return nil
}
//nolint:gosec // G202: the concatenation is a builder of "(?,…)" placeholder groups; every value is bound
_, err := tx.Exec(prefix+sb.b.String(), sb.args...)
sb.b.Reset()
sb.args = sb.args[:0]
return err
}
// alphaInsertMessages writes all 20,000 messages in chronological order —
// per-bucket second offsets are sorted before ids are assigned, so ascending
// ids follow ascending timestamps exactly as insertion order does on a live
// server (Codex on #1469, P2) — and returns each message's timestamp and
// author (indexed by id-1) for the attachment rows. Authors are drawn only
// from users who exist at the message's time: the eligible set is always the
// prefix 1..maxUser because alphaJoinTimes is monotonic, and a DM pair
// carries traffic only from its ready time.
func alphaInsertMessages(tx *sql.Tx, rng *rand.Rand, windowStart time.Time, joined []time.Time, pairs []dmPair) ([]time.Time, []int64, error) {
// Exactly 15% of message ids are DM messages.
dmCount := int(float64(alphaMessages) * alphaDMShare)
isDM := make([]bool, alphaMessages)
for _, idx := range rng.Perm(alphaMessages)[:dmCount] {
isDM[idx] = true
}
perDay := largestRemainder(onesSlice(alphaWindowDays), alphaMessages)
// Pairs sorted by readiness, so the eligible set at any time is a prefix;
// skewedIndex over that prefix keeps the earliest (all-pre-window) pairs
// the chattiest, which is also the realistic shape.
byReady := make([]dmPair, len(pairs))
copy(byReady, pairs)
sort.Slice(byReady, func(i, j int) bool { return byReady[i].ready.Before(byReady[j].ready) })
times := make([]time.Time, 0, alphaMessages)
authors := make([]int64, 0, alphaMessages)
maxUser := 40
readyPairs := 0
const chunkArgs = 500 * 5
var batch sqlBatch
const insertPrefix = `INSERT INTO messages (id, channel_id, user_id, content, timestamp) VALUES `
id := 0
for day := range alphaWindowDays {
perHour := largestRemainder(alphaHourWeights[:], perDay[day])
for hour := range 24 {
offsets := make([]int, perHour[hour])
for k := range offsets {
offsets[k] = rng.Intn(3600)
}
sort.Ints(offsets)
base := windowStart.AddDate(0, 0, day).Add(time.Duration(hour) * time.Hour)
for _, off := range offsets {
at := base.Add(time.Duration(off) * time.Second)
for maxUser < alphaUsers && !joined[maxUser+1].After(at) {
maxUser++
}
for readyPairs < len(byReady) && !byReady[readyPairs].ready.After(at) {
readyPairs++
}
var ch, author int64
if isDM[id] && readyPairs > 0 {
p := byReady[skewedIndex(rng, readyPairs)]
ch = p.channelID
if rng.Intn(2) == 0 {
author = p.a
} else {
author = p.b
}
} else {
ch = alphaChannelFor(rng, day)
author = alphaAuthorFor(rng, ch, maxUser)
}
id++
times = append(times, at)
authors = append(authors, author)
batch.add("(?,?,?,?,?)", id, ch, author, sentence(rng), ts(at))
if len(batch.args) >= chunkArgs {
if err := batch.flush(tx, insertPrefix); err != nil {
return nil, nil, fmt.Errorf("alpha: messages: %w", err)
}
}
}
}
}
if err := batch.flush(tx, insertPrefix); err != nil {
return nil, nil, fmt.Errorf("alpha: messages: %w", err)
}
return times, authors, nil
}
// ─── Attachments, reactions, invites ────────────────────────────────────────
func alphaInsertAttachments(tx *sql.Tx, rng *rand.Rand, messageTimes []time.Time, messageAuthors []int64) error {
type class struct {
count int
mime, ext string
minKB, maxKB int
namePrefix string
}
classes := []class{
{alphaAttachments * 60 / 100, "image/png", "png", 10, 900, "screenshot"},
{alphaAttachments * 10 / 100, "audio/ogg", "ogg", 50, 2000, "voice-note"},
{alphaAttachments * 10 / 100, "video/mp4", "mp4", 500, 5000, "clip"},
{alphaAttachments * 20 / 100, "application/pdf", "pdf", 10, 1500, "doc"},
}
n := 0
for _, c := range classes {
for k := 0; k < c.count; k++ {
n++
msgID := rng.Intn(alphaMessages) + 1
id := fmt.Sprintf("%08x%08x%08x%08x", rng.Uint32(), rng.Uint32(), rng.Uint32(), rng.Uint32())
size := (c.minKB + rng.Intn(c.maxKB-c.minKB+1)) * 1024
// The uploader is the message's author — CreateAttachment always
// records one on the current schema (Codex on #1469, P2).
if _, err := tx.Exec(
`INSERT INTO attachments (id, message_id, filename, stored_as, mime_type, size, uploaded_at, uploader_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, msgID,
fmt.Sprintf("%s-%03d.%s", c.namePrefix, n, c.ext),
id+"."+c.ext, c.mime, size, ts(messageTimes[msgID-1]), messageAuthors[msgID-1],
); err != nil {
return fmt.Errorf("alpha: attachment %d: %w", n, err)
}
}
}
return nil
}
func alphaInsertReactions(tx *sql.Tx, rng *rand.Rand) error {
emojis := []string{"👍", "😂", "🎉", "❤️", "🔥", "😮"}
type key struct {
m, u int64
e string
}
seen := map[key]bool{}
id := 0
for id < alphaReactions {
k := key{
m: int64(rng.Intn(alphaMessages) + 1),
u: int64(rng.Intn(alphaUsers) + 1),
e: emojis[rng.Intn(len(emojis))],
}
if seen[k] {
continue
}
seen[k] = true
id++
if _, err := tx.Exec(
`INSERT INTO reactions (id, message_id, user_id, emoji) VALUES (?, ?, ?, ?)`,
id, k.m, k.u, k.e,
); err != nil {
return fmt.Errorf("alpha: reaction %d: %w", id, err)
}
}
return nil
}
func alphaInsertInvites(tx *sql.Tx, rng *rand.Rand, windowStart time.Time) error {
staffCount := alphaOwners + alphaAdmins + alphaModerators
for i := 1; i <= alphaInvites; i++ {
revoked := 0
if i <= alphaInvitesRevkd {
revoked = 1
}
var maxUses any
if i%3 == 0 {
maxUses = 10
}
var expires any
if i%4 == 0 {
expires = ts(alphaWindowEnd.AddDate(0, 0, 7))
}
if _, err := tx.Exec(
`INSERT INTO invites (id, code, created_by, max_uses, use_count, expires_at, created_at, revoked)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
i, fmt.Sprintf("ALPHA-INV-%02d", i), rng.Intn(staffCount)+1,
maxUses, rng.Intn(8), expires,
ts(windowStart.Add(time.Duration(i)*13*time.Hour)), revoked,
); err != nil {
return fmt.Errorf("alpha: invite %d: %w", i, err)
}
}
return nil
}
// ─── Small helpers ──────────────────────────────────────────────────────────
// ts renders a time the way the schema's datetime('now') defaults do.
func ts(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") }
// sentence builds 314 lexicon words; median chat-message length.
func sentence(rng *rand.Rand) string {
n := 3 + rng.Intn(12)
words := make([]string, n)
for i := range words {
words[i] = alphaLexicon[rng.Intn(len(alphaLexicon))]
}
return strings.Join(words, " ")
}
// skewedIndex prefers low indexes (min of two uniforms), so a few DM pairs
// are chatty and the tail is sparse — the shape real DM traffic has.
func skewedIndex(rng *rand.Rand, n int) int {
a, b := rng.Intn(n), rng.Intn(n)
if b < a {
return b
}
return a
}
// largestRemainder allocates total across weights, summing exactly to total.
func largestRemainder(weights []int, total int) []int {
sum := 0
for _, w := range weights {
sum += w
}
out := make([]int, len(weights))
type rem struct {
i int
frac float64
}
rems := make([]rem, len(weights))
assigned := 0
for i, w := range weights {
exact := float64(total) * float64(w) / float64(sum)
out[i] = int(exact)
assigned += out[i]
rems[i] = rem{i, exact - float64(out[i])}
}
// Stable selection sort by remainder — deterministic, n is tiny.
for assigned < total {
best := 0
for j := 1; j < len(rems); j++ {
if rems[j].frac > rems[best].frac {
best = j
}
}
out[rems[best].i]++
rems[best].frac = -1
assigned++
}
return out
}
func onesSlice(n int) []int {
s := make([]int, n)
for i := range s {
s[i] = 1
}
return s
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/J3vb/OwnCord/Server/db"
)
// generateSnapshot runs the full alpha pipeline — migrate, seed, scrub,
// VACUUM INTO — exactly as `-profile alpha -snapshot … -scrub …` does, and
// returns the snapshot's bytes.
func generateSnapshot(t *testing.T, name string) []byte {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, name+".db"))
if err != nil {
t.Fatalf("db.Open: %v", err)
}
defer func() { _ = database.Close() }()
if err := db.Migrate(database); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
if err := seedAlpha(database); err != nil {
t.Fatalf("seedAlpha: %v", err)
}
out := filepath.Join(dir, name+".sqlite")
if err := writeSnapshot(database, filepath.Join("..", "..", "testdata", "snapshots", "scrub.sql"), out); err != nil {
t.Fatalf("writeSnapshot: %v", err)
}
raw, err := os.ReadFile(out)
if err != nil {
t.Fatalf("reading snapshot: %v", err)
}
return raw
}
// TestAlphaProfileByteIdentical is the B3-7 exit condition: the profile is a
// pure function — two runs, two files, identical bytes. Anything wall-clock,
// salted, map-ordered or pool-raced in the pipeline fails this test.
func TestAlphaProfileByteIdentical(t *testing.T) {
first := generateSnapshot(t, "first")
second := generateSnapshot(t, "second")
if !bytes.Equal(first, second) {
i := 0
for i < len(first) && i < len(second) && first[i] == second[i] {
i++
}
t.Fatalf("two alpha seed runs differ (len %d vs %d, first divergence at byte %d) — the profile has a nondeterminism leak", len(first), len(second), i)
}
}
// TestAlphaProfileRefusesNonEmptyDatabase pins the guard: determinism is
// per-database-lifetime, so the profile only ever seeds an empty file.
func TestAlphaProfileRefusesNonEmptyDatabase(t *testing.T) {
database, err := db.Open(filepath.Join(t.TempDir(), "occupied.db"))
if err != nil {
t.Fatalf("db.Open: %v", err)
}
defer func() { _ = database.Close() }()
if err := db.Migrate(database); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
if err := seedAlpha(database); err != nil {
t.Fatalf("first seed: %v", err)
}
if err := seedAlpha(database); err == nil {
t.Fatal("second seed on a populated database succeeded; want a refusal")
}
}
+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)
+135
View File
@@ -0,0 +1,135 @@
package db
import (
"context"
"io"
"os"
"path/filepath"
"testing"
)
// The committed alpha snapshot (B3-7 item 3) — the "upgrade still applies"
// canary. It opens Server/testdata/snapshots/v1.2.0-alpha.4.sqlite on a
// throwaway copy, asserts its recorded provenance, runs HEAD's migrations
// over it, and checks the row counts the profile promises. The moment a
// post-alpha.4 migration lands, Migrate below is a real upgrade rehearsal:
// it must apply the delta cleanly or this test is the first thing to break.
//
// alphaSnapshotMigrations is the schema_versions count baked into the
// committed snapshot — 31, the migration set at the v1.2.0-alpha.4 tag
// (unchanged through B3). It only moves when the snapshot is REGENERATED on
// a newer schema, which is a deliberate act recorded in the B3-7 evidence
// block, never a drive-by.
const alphaSnapshotMigrations = 31
func openSnapshotCopy(t *testing.T) *DB {
t.Helper()
src, err := os.Open(filepath.Join("..", "testdata", "snapshots", "v1.2.0-alpha.4.sqlite"))
if err != nil {
t.Fatalf("opening committed snapshot: %v", err)
}
defer func() { _ = src.Close() }()
dstPath := filepath.Join(t.TempDir(), "alpha-copy.sqlite")
dst, err := os.Create(dstPath)
if err != nil {
t.Fatalf("creating copy: %v", err)
}
if _, err := io.Copy(dst, src); err != nil {
t.Fatalf("copying snapshot: %v", err)
}
if err := dst.Close(); err != nil {
t.Fatalf("closing copy: %v", err)
}
database, err := Open(dstPath)
if err != nil {
t.Fatalf("db.Open on snapshot copy: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func TestAlphaSnapshotMigratesOnHead(t *testing.T) {
database := openSnapshotCopy(t)
count := func(q string) int {
t.Helper()
var n int
if err := database.QueryRowContext(context.Background(), q).Scan(&n); err != nil {
t.Fatalf("%s: %v", q, err)
}
return n
}
// Provenance: the snapshot was created at the alpha.4 migration set.
if got := count(`SELECT COUNT(*) FROM schema_versions`); got != alphaSnapshotMigrations {
t.Fatalf("snapshot carries %d applied migrations, want %d — regenerate it deliberately or fix the constant with the B3-7 evidence block", got, alphaSnapshotMigrations)
}
// The upgrade: HEAD's migrations must apply to an alpha.4 database.
if err := Migrate(database); err != nil {
t.Fatalf("HEAD migrations failed on the alpha.4 snapshot: %v", err)
}
if got := count(`SELECT COUNT(*) FROM schema_versions`); got < alphaSnapshotMigrations {
t.Fatalf("schema_versions shrank to %d after Migrate", got)
}
// The row-count checksum: what cmd/seed's alpha profile promises.
for _, tc := range []struct {
q string
want int
}{
{`SELECT COUNT(*) FROM users`, 100},
{`SELECT COUNT(*) FROM channels`, 52}, // 12 server + 40 DM
{`SELECT COUNT(*) FROM channels WHERE type = 'dm'`, 40},
{`SELECT COUNT(*) FROM channels WHERE archived = 1`, 1},
{`SELECT COUNT(*) FROM channel_overrides`, 4}, // 3 channels carry them
{`SELECT COUNT(*) FROM channel_user_overrides`, 2}, // 2 channels carry them
{`SELECT COUNT(*) FROM messages`, 20000},
{`SELECT COUNT(*) FROM messages m JOIN channels c ON c.id = m.channel_id AND c.type = 'dm'`, 3000},
{`SELECT COUNT(*) FROM dm_participants`, 80},
{`SELECT COUNT(*) FROM attachments`, 300},
{`SELECT COUNT(*) FROM reactions`, 500},
{`SELECT COUNT(*) FROM invites`, 30},
{`SELECT COUNT(*) FROM invites WHERE revoked = 1`, 10},
{`SELECT COUNT(*) FROM plugins`, 1},
{`SELECT COUNT(*) FROM plugins WHERE enabled = 0`, 1},
// A quiescent, scrubbed server carries none of these — see the
// profile's package comment (voice is ephemeral; the replay log is
// empty on a restarted server; sessions and tokens are scrubbed).
{`SELECT COUNT(*) FROM sessions`, 0},
{`SELECT COUNT(*) FROM api_tokens`, 0},
{`SELECT COUNT(*) FROM events`, 0},
{`SELECT COUNT(*) FROM voice_states`, 0},
} {
if got := count(tc.q); got != tc.want {
t.Errorf("%s = %d, want %d", tc.q, got, tc.want)
}
}
// FTS came along: the virtual table indexes every message.
if got := count(`SELECT COUNT(*) FROM messages_fts`); got != 20000 {
t.Errorf("messages_fts holds %d rows, want 20000", got)
}
// Realism invariants a live database cannot violate (Codex on #1469):
// nothing is authored before its author joined, ids follow timestamps as
// insertion order does, and every attachment records its uploader.
for _, tc := range []struct {
what, q string
}{
{"messages authored before their author joined",
`SELECT COUNT(*) FROM messages m JOIN users u ON u.id = m.user_id WHERE m.timestamp < u.created_at`},
{"adjacent message ids with inverted timestamps",
`SELECT COUNT(*) FROM messages a JOIN messages b ON b.id = a.id + 1 WHERE b.timestamp < a.timestamp`},
{"attachments without an uploader",
`SELECT COUNT(*) FROM attachments WHERE uploader_id IS NULL`},
{"DM messages before both members existed",
`SELECT COUNT(*) FROM messages m JOIN channels c ON c.id = m.channel_id AND c.type = 'dm'
JOIN dm_participants dp ON dp.channel_id = c.id JOIN users u ON u.id = dp.user_id
WHERE m.timestamp < u.created_at`},
} {
if got := count(tc.q); got != 0 {
t.Errorf("%s: %d, want 0", tc.what, got)
}
}
}
+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
}
+11 -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"},
@@ -80,6 +79,7 @@ var DBImportAllow = map[string]DBImportEntry{
"internal/app/plugins.go": {"boundary", "", "passes the handle to the plugin registry as its store; no calls"},
"token_cli.go": {"move", "auth", "API-token CLI duplicates admin/handlers_tokens.go"},
"cmd/seed/main.go": {"boundary", "", "developer seeding tool owns its handle"},
"cmd/seed/profile_alpha.go": {"boundary", "", "the alpha profile writes through the handle main.go owns"},
"cmd/gendocs/main.go": {"boundary", "", "docs generator migrates its own in-memory catalog"},
"plugin/pluginstore.go": {"adapter", "", "PluginRow type only; the store is injected"},
// ── ws ────────────────────────────────────────────────────────────────
@@ -90,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)
}
}
+60
View File
@@ -0,0 +1,60 @@
# Alpha snapshots
`v1.2.0-alpha.4.sqlite` is the anonymised, deterministic database snapshot the
B3-7 plan item promises: a server shaped like a real `v1.2.0-alpha.4` install
after a month of use, at rest between sessions. The schema is the alpha.4
migration set (31 applied migrations — unchanged since the tag), so opening it
with a newer server is a genuine in-place upgrade.
## Consumers
- **B4 — HP-4 drills**: identity/recovery/deletion rehearsals against
realistic data.
- **B6 — upgrade rehearsal**: the deployment qualification's alpha→beta
in-place upgrade input.
- **B10 — in-place upgrade**: the release candidate's required upgrade test
(roadmap B10 item 4).
- `Server/db/alpha_snapshot_test.go` is the standing canary: every `go test`
run opens a copy, applies HEAD's migrations, and checks the row counts.
## Shape
100 users (1 owner / 2 admins / 5 moderators / 92 members), 12 channels
(10 text + 2 voice; 3 with role overrides, 2 with user overrides, 1 archived),
20,000 messages over 30 simulated days on a diurnal curve (15% in DMs across
40 DM pairs), 300 attachment rows (60/10/10/20% image/audio/video/other,
10 KB5 MB), 500 reactions, 30 invites (10 revoked), 1 disabled plugin row.
The numbers live as constants in `Server/cmd/seed/profile_alpha.go`; a number
found unrepresentative is changed there with the reason recorded in the B3-7
evidence block — never silently. Two constants deliberately leave no rows
(voice sessions are LiveKit-ephemeral; the replay log is empty as on any
server restarted for an upgrade) — the profile's package comment records why.
Every account's password is `alpha-dev-password`. Attachment rows are
metadata only — the files themselves are not part of a database snapshot, so
attachment downloads 404 until B6's rehearsal seeds an uploads directory to
match.
## Regenerating
```bash
cd Server
go run ./cmd/seed -confirm-dev -profile alpha \
-db /tmp/alpha-fresh.db \
-snapshot testdata/snapshots/v1.2.0-alpha.4.sqlite \
-scrub testdata/snapshots/scrub.sql
```
The output is byte-for-byte reproducible (`TestAlphaProfileByteIdentical`
holds the property); regenerate only deliberately — on a newer migration set
the snapshot stops being an alpha.4 artifact, `alphaSnapshotMigrations` in
the canary must move with it, and the change belongs in the B3-7 evidence
block.
`scrub.sql` is the identity and credential scrub: usernames, profiles,
secrets, sessions, tokens, invite codes, audit detail, filenames and
wall-clock migration timestamps. It deliberately does not touch message
content or channel names — synthetic here, but a judgement call on a real
database — so a donated production database is **not** shareable after this
script alone: clear or rewrite its content (and rebuild FTS) first, or
refuse the donation.
+58
View File
@@ -0,0 +1,58 @@
-- Identity and credential scrub for alpha snapshots (B3-7 item 2).
--
-- Applied by `go run ./cmd/seed -profile alpha -snapshot … -scrub this-file`
-- before VACUUM INTO writes the committed snapshot. Every statement is
-- idempotent. Scope, stated precisely (Codex on #1469): this script removes
-- account identities, credentials, session/token material, invite codes,
-- audit detail, user-chosen filenames and wall-clock apply times. It
-- DELIBERATELY DOES NOT touch message content, channel names/topics, or the
-- FTS index built from them — the committed snapshot's content is synthetic
-- lexicon text with nothing to hide, and content anonymisation of a REAL
-- database is a judgement call no blanket UPDATE can make. A donated
-- production database is NOT made shareable by this script alone: its
-- messages and channel names must be cleared or rewritten first (and the
-- FTS index rebuilt), or the donation refused. Statements are split on ";"
-- with comment lines stripped, so keep one statement per block and comments
-- on their own lines.
UPDATE users
SET username = 'user' || printf('%03d', id),
display_name = NULL,
about = NULL,
custom_status = NULL,
avatar = NULL,
totp_secret = NULL,
identity_public_key = NULL,
ban_reason = CASE WHEN ban_reason IS NULL THEN NULL ELSE 'scrubbed' END,
password = '$2a$12$EBrRXmplT1ryU0o/HzELSePreo.gK5.z5Tjo4ec/ISchy5gKwxtQq';
-- Sessions, tokens and rate-limit state authenticate or profile people —
-- a snapshot carries none of them.
DELETE FROM sessions;
DELETE FROM api_tokens;
DELETE FROM login_attempts;
DELETE FROM rate_lockouts;
-- Invite codes are shared secrets; keep the rows, rotate the codes.
UPDATE invites SET code = 'ALPHA-INV-' || printf('%02d', id);
-- Audit detail can quote user-entered text; the action skeleton is enough.
UPDATE audit_log SET detail = '';
-- Migration tracking records wall-clock apply times; the snapshot's
-- canonical form pins them to the window end so two generation runs produce
-- identical bytes (and a real donated database stops dating its operator).
UPDATE schema_versions SET applied_at = '2026-08-01 00:00:00';
-- Attachment filenames are user-chosen text.
UPDATE attachments
SET filename = 'file-' || substr(id, 1, 8) ||
CASE
WHEN mime_type LIKE 'image/%' THEN '.png'
WHEN mime_type LIKE 'audio/%' THEN '.ogg'
WHEN mime_type LIKE 'video/%' THEN '.mp4'
ELSE '.bin'
END;
Binary file not shown.
@@ -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()

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