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>
This commit is contained in:
J3vb
2026-08-31 15:13:38 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent 528ae260ee
commit 63c87df487
54 changed files with 891 additions and 498 deletions
+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)
+3 -3
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
@@ -157,8 +157,8 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
}))
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(handleBackup(database)).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)
+4 -2
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"
@@ -287,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 {
@@ -366,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()
+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)
+2 -1
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"
)
@@ -264,7 +265,7 @@ func newBareHub(t *testing.T, lk *ws.LiveKitClient) *ws.Hub {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
hub, err := ws.NewHub(ws.HubOptions{DB: database, Limiter: auth.NewRateLimiter(), LiveKit: lk})
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)
}
+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))
+1 -1
View File
@@ -6,7 +6,7 @@
"auth": 90.8,
"db": 79.3,
"permissions": 100.0,
"service": 67.8,
"service": 69.2,
"ws": 86.7
}
}
+26
View File
@@ -5,8 +5,10 @@ import (
"database/sql"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"github.com/J3vb/OwnCord/Server/db/dbgen"
@@ -323,6 +325,30 @@ func (d *DB) SetSetting(ctx context.Context, key, value string) error {
return nil
}
// ApplySettings upserts every key→value pair in one transaction, so a
// mid-loop failure leaves no partial update behind. Keys are applied in
// sorted order for deterministic behaviour under test.
func (d *DB) ApplySettings(ctx context.Context, updates map[string]string) error {
if len(updates) == 0 {
return nil
}
tx, err := d.writer.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ApplySettings: begin: %w", err)
}
q := d.q.WithTx(tx)
for _, key := range slices.Sorted(maps.Keys(updates)) {
if err := q.SetSetting(ctx, dbgen.SetSettingParams{Key: key, Value: updates[key]}); err != nil {
_ = tx.Rollback()
return fmt.Errorf("ApplySettings: %s: %w", key, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ApplySettings: commit: %w", err)
}
return nil
}
// GetAllSettings returns all settings as a key→value map.
func (d *DB) GetAllSettings(ctx context.Context) (map[string]string, error) {
rows, err := d.q.GetAllSettings(ctx)
+58
View File
@@ -0,0 +1,58 @@
package db_test
import (
"context"
"testing"
)
// ApplySettings targets the real settings table, so these tests use the
// package's full-migration opener (migrated_db_test.go), not the minimal
// shared testSchema fixture.
func TestApplySettings_AppliesEveryKey(t *testing.T) {
database := newMigratedTestDB(t)
if err := database.ApplySettings(context.Background(), map[string]string{
"server_name": "Applied",
"motd": "Hello",
}); err != nil {
t.Fatalf("ApplySettings: %v", err)
}
for key, want := range map[string]string{"server_name": "Applied", "motd": "Hello"} {
got, err := database.GetSetting(context.Background(), key)
if err != nil || got != want {
t.Fatalf("GetSetting(%s) = %q, %v; want %q", key, got, err, want)
}
}
}
func TestApplySettings_EmptyMapIsNoOp(t *testing.T) {
database := newMigratedTestDB(t)
if err := database.ApplySettings(context.Background(), nil); err != nil {
t.Fatalf("ApplySettings(nil): %v", err)
}
}
func TestApplySettings_RollsBackOnFailure(t *testing.T) {
database := newMigratedTestDB(t)
// Sabotage the table so the in-transaction upsert fails after Begin
// succeeded — the rollback path must surface the error, and a later
// repair must find no half-applied state (the transaction is the unit).
if _, err := database.ExecContext(context.Background(), "DROP TABLE settings"); err != nil {
t.Fatalf("DROP TABLE settings: %v", err)
}
if err := database.ApplySettings(context.Background(), map[string]string{
"server_name": "never",
}); err == nil {
t.Fatal("ApplySettings against a dropped table must error")
}
}
func TestApplySettings_BeginFailsOnClosedDB(t *testing.T) {
database := newMigratedTestDB(t)
_ = database.Close()
if err := database.ApplySettings(context.Background(), map[string]string{
"server_name": "never",
}); err == nil {
t.Fatal("ApplySettings on a closed database must error")
}
}
+1
View File
@@ -49,6 +49,7 @@ func StartRuntime(cfg *config.Config, database *db.DB, pluginRegistry *plugin.Re
DB: database,
Limiter: limiter,
Services: svc,
Settings: svc.Settings,
// nil pluginRegistry means plugins are disabled; the hub no-ops.
PluginRegistry: pluginRegistry,
LiveKit: lk,
+1 -1
View File
@@ -266,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
+4 -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,7 @@ func TestSeedHubReplayState_ForcesFullResyncForOfflineClient(t *testing.T) {
}
limiter := auth.NewRateLimiter()
hub, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter})
hub, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
@@ -263,7 +264,7 @@ func TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync(t *t
// last_seq ends up as whatever the 40th broadcast's real seq turns out to
// be — captured dynamically so this test holds regardless of what value
// scheme is in effect (raw 1..N pre-fix, or a seeded floor post-fix). ---
hubOld, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter})
hubOld, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
@@ -281,7 +282,7 @@ func TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync(t *t
// --- Restart: hub B is a brand-new process-equivalent hub, same disabled
// config, same (in-memory but never touched by persistence) database. ---
hubNew, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter})
hubNew, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
if hubErr != nil {
t.Fatalf("ws.NewHub: %v", hubErr)
}
+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
}
+2 -4
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"},
@@ -95,7 +94,6 @@ var DBImportAllow = map[string]DBImportEntry{
"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_settings.go": {"move", "settings-ops", "settings cache reads server name and MOTD through h.db; import pinned so the rule sees it"},
"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"},
+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)
}
}
+4
View File
@@ -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
+11
View File
@@ -34,6 +34,12 @@ type HubOptions struct {
// 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
@@ -67,6 +73,9 @@ func NewHub(opts HubOptions) (*Hub, error) {
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")
}
@@ -75,6 +84,7 @@ func NewHub(opts HubOptions) (*Hub, error) {
}
database, limiter, svc := opts.DB, opts.Limiter, opts.Services
settingsReader := opts.Settings
ringSize := 1000
if opts.ReplayRingSize > 0 {
@@ -87,6 +97,7 @@ func NewHub(opts HubOptions) (*Hub, error) {
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{}),
+11 -14
View File
@@ -3,15 +3,15 @@ package ws
import (
"context"
"time"
"github.com/J3vb/OwnCord/Server/db"
)
// The db-import-boundary rule and the boundaries-doc inventory track files by
// their db import. This file's persistence runs through the h.db field, which
// needs no import — so the import is pinned here deliberately, keeping the
// settings-ops reads on the inventory's books instead of invisible to it.
var _ *db.DB
// 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) {
@@ -33,20 +33,17 @@ func (h *Hub) getCachedSettings(ctx context.Context) (string, string) {
return h.settingsName, h.settingsMotd
}
// refreshSettingsLocked reloads server_name and motd from the DB.
// Caller must hold settingsMu (write lock) or call during init.
// 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) {
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 {
if name, err := h.settings.Setting(ctx, "server_name"); err == nil {
h.settingsName = name
}
if motd, err := h.db.GetSetting(ctx, "motd"); err == nil {
if motd, err := h.settings.Setting(ctx, "motd"); err == nil {
h.settingsMotd = motd
}
h.settingsLastUpdate = time.Now()
+6 -1
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"
)
@@ -690,7 +691,11 @@ func TestNewHub_RequiredCollaborators(t *testing.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(), ReplayRingSize: -1}); err == nil {
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")
}
}
+3
View File
@@ -28,6 +28,9 @@ func newTestHubWith(tb testing.TB, opts ws.HubOptions) *ws.Hub {
if opts.Limiter == nil {
opts.Limiter = auth.NewRateLimiter()
}
if opts.Settings == nil && opts.DB != nil {
opts.Settings = service.NewSettingsService(opts.DB)
}
h, err := ws.NewHub(opts)
if err != nil {
tb.Fatalf("ws.NewHub: %v", err)
+3
View File
@@ -29,6 +29,9 @@ func newTestHubWith(tb testing.TB, opts HubOptions) *Hub {
if opts.Limiter == nil {
opts.Limiter = auth.NewRateLimiter()
}
if opts.Settings == nil && opts.DB != nil {
opts.Settings = service.NewSettingsService(opts.DB)
}
h, err := NewHub(opts)
if err != nil {
tb.Fatalf("NewHub: %v", err)
+77 -68
View File
@@ -31,7 +31,18 @@ be invisible to both, which a review flagged on the finisher PR. `hub.go`
and the new `hub_options.go` are type-only `boundary` rows
holding/validating the handle. The disposition counts were also
re-derived from the tool's summary: `boundary` had been stale at 12
since the seed-profile row landed).
since the seed-profile row landed); 2026-08-31 (B3-8, settings/audit
family) — the first table, on `feat/b3-8-settings-family`: the family's
persistence now lives only in `db/` and `service/`. The thinned
`admin/handlers_settings.go` and the reader-backed `ws/hub_settings.go`
(pin removed — the file no longer touches `db` at all) stop importing
`db`, so both rows are deleted; the backup pair takes the disposition
this table forecast — `boundary` — now that settings-ops owns the
settings (`backup_maintenance.go` reads schedule/retention through the
service and keeps only backup mechanics; `handlers_backup.go` owns the
handle for VACUUM INTO, the WAL checkpoint and close-and-swap restore).
`settings-ops` disappears from the move targets: 28 → 24 `move`,
15 → 17 `boundary`.
**Owner:** the B3 plan,
[plans/b3-server-architecture-guardrails-2026-08-29.md](../plans/b3-server-architecture-guardrails-2026-08-29.md).
**Regenerate the first table:** `cd Server && go run ./cmd/dbinventory` and
@@ -49,9 +60,9 @@ happens to that use — one of four dispositions from the
| Disposition | Meaning | Rows |
| ----------- | --------------------------------------------------------------------------------------------------------------------- | ---: |
| `move` | persistence or a domain decision that belongs behind a service; **Family** names the service B3-8 (or B3-2) builds | 28 |
| `move` | persistence or a domain decision that belongs behind a service; **Family** names the service B3-8 (or B3-2) builds | 24 |
| `adapter` | a transport adapter that uses `db` types or pure helpers only — response shapes, status helpers — no persistence call | 18 |
| `boundary` | an explicit composition or transaction boundary that legitimately owns a handle (process entry, CLIs, health probe) | 15 |
| `boundary` | an explicit composition or transaction boundary that legitimately owns a handle (process entry, CLIs, health probe) | 17 |
| `remove` | the import is unnecessary and goes | 0 |
The rows live in code, not only here: `Server/invariants/db_import_boundary.go`
@@ -92,72 +103,70 @@ which is a row worth reading, and none exists today.
<!-- dbinventory:start -->
| File | `db.*` types | `db.*` funcs and sentinels | `*db.DB` method calls | Shape | Disposition | Family | Why |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | ------------ | ------------------------------------------------------------------------------------------------- |
| `admin/admin.go` | `DB` | — | — | type-only | boundary | — | holds the handle for the admin mux; no calls |
| `admin/api.go` | `DB` | — | — | type-only | boundary | — | passes the handle to handlers; no calls |
| `admin/backup_maintenance.go` | `DB×3` | `CheckBackupIntegrity()` `ErrNotFound×2` `WriteAudit()×2` | `BackupToSafe` `GetSetting×2` | calls | move | settings-ops | BackupToSafe, integrity check, settings reads |
| `admin/handlers_backup.go` | `DB×5` | `CheckBackupIntegrity()×2` `WriteAudit()×2` | `BackupToSafe×2` `Close` `LogAudit` `SQLDb` | calls | move | settings-ops | backup trigger and download; raw SQLDb for VACUUM INTO |
| `admin/handlers_channel_perms.go` | `Channel` `ChannelRoleOverride×2` `ChannelUserOverride×2` `DB×8` `Role×2` `User×2` | `WriteAudit()×4` | `DeleteChannelOverride` `DeleteChannelUserOverride` `GetChannel` `GetChannelPermissions×2` `GetRoleByID×3` `GetUserByID` `GetUserChannelPermissions×2` `ListChannelRoleOverrides` `ListChannelUserOverrides` `ListUserIDsByRole×2` `UpsertChannelOverride` `UpsertChannelUserOverride` | calls | move | channel | override CRUD decides permission policy in the handler |
| `admin/handlers_channels.go` | `Channel×2` `ChannelUpdate×2` `DB×6` | `WriteAudit()×3` | `AdminCreateChannel` `AdminDeleteChannel` `AdminUpdateChannel×2` `GetAuditLog` `GetChannel×4` `ListChannels` | calls | move | channel | channel CRUD + audit |
| `admin/handlers_roles.go` | `DB×4` | — | `GetRoleByID` `ListRoles` | calls | move | role | two reads; service/role.go already owns the writes |
| `admin/handlers_settings.go` | `DB×4` | `ErrNotFound` `WriteAudit()` | `BeginTx` `CountUsersWithoutTOTP` `GetAllSettings×2` `GetSetting` | calls | move | settings-ops | BeginTx in a handler; TOTP census |
| `admin/handlers_tokens.go` | `DB×3` `User` | `WriteAudit()×2` | `CreateAPIToken` `GetOwnerUser` `GetUserByUsername` `ListAPITokens` `RevokeAPIToken` | calls | move | auth | API-token CRUD duplicated in token_cli.go |
| `admin/handlers_users.go` | `DB×4` `Role` `User` | — | `GetServerStats` `GetUserByID×2` `ListAllUsers` | calls | move | user | user list, stats, lookups |
| `admin/helpers.go` | `Role×2` `User` | — | — | type-only | adapter | — | Role/User types in response helpers |
| `admin/logstream.go` | `DB×3` | — | — | type-only | boundary | — | handle threaded to the SSE stream's auth check; no calls |
| `admin/middleware.go` | `DB×2` `Role×2` | — | — | type-only | move | auth | owner gate re-reads the role — OC-0345 |
| `admin/setup_handler.go` | `DB×4` | `ErrConflict` `WriteAudit()×2` | `CreateChannel×2` `CreateInvite` `CreateOwnerIfEmpty` `CreateSession` `GetSetting×3` `UserCount` | calls | move | auth | first-run owner creation (setup sub-family) |
| `admin/setup_wizard.go` | `DB` | — | `BeginTx` | calls | move | auth | BeginTx for the wizard; setup sub-family |
| `admin/types.go` | `Channel×3` `DB` `Role` `User` `UserWithRole` | — | `GetRoleByID` | calls | adapter | — | response DTOs; the one GetRoleByID moves with handlers_users |
| `api/channel_handler.go` | `DB` `MessageAPIResponse×2` `MessageSearchResult×2` `ReactionUser` `User×8` | — | — | type-only | adapter | — | response types only; service owns the calls |
| `api/dm_handler.go` | `DB` `DMChannelInfo` `DMUser×2` `User×8` | `NewDMChannelInfo()` `StatusForViewer()` | — | calls | adapter | — | DM response types + pure status helpers |
| `api/emoji_handler.go` | `DB` `Emoji×3` `User×2` | — | — | type-only | adapter | — | Emoji/User types only |
| `api/gif_handler.go` | `DB` | — | — | type-only | adapter | — | handle in the signature, unused for calls |
| `api/invite_handler.go` | `DB` `Invite` `User×2` | — | | type-only | adapter | — | Invite/User types only |
| `api/middleware.go` | `DB` `Role` `Session` `User` | — | `DeleteSession` `TouchAPIToken` `TouchSession` | calls | move | auth | session/API-token touch and revoke |
| `api/plugins_handler.go` | `Auditor×2` | `WriteAudit()` | | calls | adapter | — | db.Auditor is the seam; WriteAudit only |
| `api/profile_handler.go` | `DB×2` `Session×2` `User×7` | — | `CreateAttachment` | calls | move | upload | avatar upload creates the attachment row |
| `api/router.go` | `DB×4` | — | `PingRead` `SQLDb` | calls | boundary | — | health probe (PingRead, SQLDb); hub construction left in B3-3 |
| `api/upload_handler.go` | `AttachmentAccess×2` `DB×5` `Role` `User×3` | — | `CreateAttachment` `GetAttachmentWithChannel` `IsAvatarFileURL` `IsDMParticipant` `QueryRowContext` | calls | move | upload | attachment access + a raw QueryRowContext |
| `auth/helpers.go` | `User` | — | — | type-only | adapter | — | db.User type in a helper signature |
| `auth/resolve.go` | `APIToken` `Role×2` `Session×2` `User×2` | — | — | type-only | adapter | — | Session/APIToken/Role/User types; resolution is injected |
| `cmd/gendocs/main.go` | `DB×3` | `Migrate()` `Open()` | `Close×2` `QueryContext×2` | calls | boundary | — | docs generator migrates its own in-memory catalog |
| `cmd/seed/main.go` | `DB×6` | `Migrate()` `Open()` | `Close` `CreateChannel` `CreateMessage×2` `CreateUser` `GetOrCreateDMChannel` `GetUserByUsername` `ListChannels` `QueryRowContext` | calls | boundary | — | developer seeding tool owns its handle |
| `cmd/seed/profile_alpha.go` | `DB×3` | `Migrate()` | `BeginTx` `ExecContext×2` `QueryRowContext` | calls | boundary | — | the alpha profile writes through the handle main.go owns |
| `internal/app/app.go` | `AuditWriter` `DB` | — | — | type-only | boundary | — | the App holds the handle for its lifetime; no calls |
| `internal/app/database.go` | `DB×2` | `Migrate()` `OpenWithMaxReaders()` | `ClearAllVoiceStates` `ResetAllUserStatuses` | calls | boundary | — | opens the handle, migrates, clears stale state at boot |
| `internal/app/hub.go` | `DB` | — | | type-only | boundary | — | hands the handle to the hub and the service layer it builds |
| `internal/app/maintenance.go` | `DB×3` | — | `DeleteExpiredSessions` `DeleteOrphanedAttachments` | calls | boundary | — | periodic worker: expired sessions, backups, orphan attachments |
| `internal/app/persistence.go` | `AuditWriter×2` `DB×4` | `ErrNotFound` `NewAuditWriter()` | `GetMaxEventSeq` `GetSetting` `SetAuditWriter` `SetSetting` | calls | boundary | — | event persister, audit writer and the boot seq seed own the handle |
| `internal/app/plugins.go` | `DB` | — | — | type-only | boundary | — | passes the handle to the plugin registry as its store; no calls |
| `plugin/pluginstore.go` | `PluginRow×3` | — | — | type-only | adapter | — | PluginRow type only; the store is injected |
| `token_cli.go` | `DB×3` `User` | `Migrate()` `OpenShared()` `WriteAudit()×3` | `Close` `CreateAPIToken` `GetOwnerUser` `GetUserByUsername` `ListAPITokens` `RevokeAPIToken` `RevokeAPITokenByLabel` | calls | move | auth | API-token CLI duplicates admin/handlers_tokens.go |
| `ws/client.go` | `User×2` | — | | type-only | adapter | — | db.User type on the connection |
| `ws/deps.go` | `Channel` `DB×5` | | `GetRoleForUser×3` `IsDMParticipant` | calls | move | channel | role and DM-membership reads behind the hub's deps |
| `ws/event.go` | — | `BroadcastStatus()` | — | calls | adapter | — | pure BroadcastStatus helper |
| `ws/event_persister.go` | `PersistedEvent×2` | — | — | type-only | adapter | — | PersistedEvent type; store is an interface |
| `ws/eventstore.go` | `PersistedEvent×3` | — | | type-only | adapter | — | PersistedEvent type; store is an interface |
| `ws/handlers.go` | `User` | — | `GetChannel` `GetRoleForUser` `GetSessionWithBanStatus` `IsDMParticipant` | calls | move | channel | channel, role, session-ban and DM reads in command handlers |
| `ws/handlers_chat.go` | — | `NewDMChannelInfo()` | — | calls | adapter | — | pure NewDMChannelInfo helper |
| `ws/hub.go` | `DB` | — | | type-only | boundary | — | Hub state holds the handle the families read through; no calls |
| `ws/hub_broadcast.go` | `Channel×2` `Emoji` `Role` | — | `GetRoleForUser` `GetUserByID` | calls | move | channel | member broadcast payloads read the user and role they announce |
| `ws/hub_options.go` | `DB` | | — | type-only | boundary | — | construction validates and stores the handle; no calls |
| `ws/hub_presence.go` | — | `BroadcastStatus()` | — | calls | adapter | — | presence coalescer; pure BroadcastStatus helper and the MemberSummary shape |
| `ws/hub_settings.go` | `DB` | — | `GetSetting×2` | calls | move | settings-ops | settings cache reads server name and MOTD through h.db; import pinned so the rule sees it |
| `ws/hub_sweep.go` | `User` | — | `GetAllVoiceStates` `GetChannel` `GetChannelVoiceStates` `GetSessionsWithBanStatusBatch` `LeaveVoiceChannelIfMatch×2` | calls | move | voice | stale-voice sweep reads and leaves |
| `ws/hub_visibility.go` | `Channel×2` `ChannelOverride` `DB` `User` | — | `GetChannel×2` `GetChannelOverridesFor` `GetDMParticipantIDs` `GetRoleByID` `GetUserByID` `GetUserDMChannelIDs` `ListChannels×2` | calls | move | channel | visibility and audience resolution reads channels, overrides, participants, users |
| `ws/messages.go` | `Channel×4` `DMChannelInfo×2` `DMUser×2` `Emoji` `Role×3` `User×2` `VoiceState` | `BroadcastStatus()` `StatusForViewer()` | — | calls | adapter | — | wire types + pure status helpers |
| `ws/replay.go` | `DB×2` `PersistedEvent` | — | — | type-only | move | connection | reconnect replay selection and delivery; serve.go's row split with its code in B3-5 |
| `ws/serve.go` | `DB×3` | `ConnectStatus()` | `GetRoleByID` `GetUserByID` `UpdateUserStatus` | calls | move | connection | connect/disconnect lifecycle; B3-5 splits it by family first |
| `ws/serve_auth.go` | `DB×2` `User` | `StatusOffline` `WriteAudit()` | `GetRoleByID` `GetSessionByTokenHash` `GetUserByID` `MarkUserDisconnected` | calls | move | auth | handshake auth: session, user and role lookups, connect audit, failed-handshake teardown |
| `ws/serve_pumps.go` | — | `StatusOffline` | `MarkUserDisconnected` | calls | move | user | MarkUserDisconnected on pump exit |
| `ws/serve_ready.go` | `Channel×10` `ChannelOverride×6` `ChannelUnread×2` `DB×7` `DMChannelInfo×4` `MemberSummary×3` `Role×4` `User` `VoiceState×5` | `StatusOffline×3` | `GetAllVoiceStates` `GetChannelOverridesFor` `GetChannelUnreadCounts` `GetRoleByID` `GetUserByID` `GetUserDMChannels` `GetVoiceState` `LeaveVoiceChannelIfMatch` `ListChannels` `ListMembers` `ListRoles` | calls | move | channel | ready snapshot and fresh-connect: channels, overrides, unreads, DMs, members, stale-voice cleanup |
| `ws/voice_join.go` | `Channel×3` `ChannelOverride` `VoiceState×5` | `ErrChannelFull` | `GetChannel×2` `GetChannelOverridesFor` `GetChannelVoiceStates` `GetRoleForUser` `GetVoiceState×6` `JoinVoiceChannel` `JoinVoiceChannelIfCapacity` `LeaveVoiceChannelIfMatch` `SetVoiceServerDeafen` `SetVoiceServerMute` | calls | move | voice | voice state reads and writes |
| `ws/voice_moderation.go` | `Role` `VoiceState×3` | `WriteAudit()` | `CountChannelVoiceUsers` `GetChannel×2` `GetRoleForUser` `GetVoiceState×2` `SetVoiceServerDeafen×2` `SetVoiceServerMute×2` | calls | move | voice | mute/deafen/move persist voice state |
| File | `db.*` types | `db.*` funcs and sentinels | `*db.DB` method calls | Shape | Disposition | Family | Why |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | ---------- | ------------------------------------------------------------------------------------------------- |
| `admin/admin.go` | `DB` | — | — | type-only | boundary | — | holds the handle for the admin mux; no calls |
| `admin/api.go` | `DB` | — | — | type-only | boundary | — | passes the handle to handlers; no calls |
| `admin/backup_maintenance.go` | `DB×3` | `CheckBackupIntegrity()` `ErrNotFound×2` `WriteAudit()×2` | `BackupToSafe` | calls | boundary | — | scheduled backup mechanics on the maintenance tick; settings via the service |
| `admin/handlers_backup.go` | `DB×5` | `CheckBackupIntegrity()×2` `WriteAudit()×2` | `BackupToSafe×2` `Close` `LogAudit` `SQLDb` | calls | boundary | — | backup create/list/delete/restore owns the handle: VACUUM INTO, WAL checkpoint, close-and-swap |
| `admin/handlers_channel_perms.go` | `Channel` `ChannelRoleOverride×2` `ChannelUserOverride×2` `DB×8` `Role×2` `User×2` | `WriteAudit()×4` | `DeleteChannelOverride` `DeleteChannelUserOverride` `GetChannel` `GetChannelPermissions×2` `GetRoleByID×3` `GetUserByID` `GetUserChannelPermissions×2` `ListChannelRoleOverrides` `ListChannelUserOverrides` `ListUserIDsByRole×2` `UpsertChannelOverride` `UpsertChannelUserOverride` | calls | move | channel | override CRUD decides permission policy in the handler |
| `admin/handlers_channels.go` | `Channel×2` `ChannelUpdate×2` `DB×6` | `WriteAudit()×3` | `AdminCreateChannel` `AdminDeleteChannel` `AdminUpdateChannel×2` `GetAuditLog` `GetChannel×4` `ListChannels` | calls | move | channel | channel CRUD + audit |
| `admin/handlers_roles.go` | `DB×4` | — | `GetRoleByID` `ListRoles` | calls | move | role | two reads; service/role.go already owns the writes |
| `admin/handlers_tokens.go` | `DB×3` `User` | `WriteAudit()×2` | `CreateAPIToken` `GetOwnerUser` `GetUserByUsername` `ListAPITokens` `RevokeAPIToken` | calls | move | auth | API-token CRUD duplicated in token_cli.go |
| `admin/handlers_users.go` | `DB×4` `Role` `User` | — | `GetServerStats` `GetUserByID×2` `ListAllUsers` | calls | move | user | user list, stats, lookups |
| `admin/helpers.go` | `Role×2` `User` | — | | type-only | adapter | — | Role/User types in response helpers |
| `admin/logstream.go` | `DB×3` | — | — | type-only | boundary | — | handle threaded to the SSE stream's auth check; no calls |
| `admin/middleware.go` | `DB×2` `Role×2` | — | — | type-only | move | auth | owner gate re-reads the role — OC-0345 |
| `admin/setup_handler.go` | `DB×4` | `ErrConflict` `WriteAudit()×2` | `CreateChannel×2` `CreateInvite` `CreateOwnerIfEmpty` `CreateSession` `GetSetting×3` `UserCount` | calls | move | auth | first-run owner creation (setup sub-family) |
| `admin/setup_wizard.go` | `DB` | — | `BeginTx` | calls | move | auth | BeginTx for the wizard; setup sub-family |
| `admin/types.go` | `Channel×3` `DB` `Role` `User` `UserWithRole` | — | `GetRoleByID` | calls | adapter | | response DTOs; the one GetRoleByID moves with handlers_users |
| `api/channel_handler.go` | `DB` `MessageAPIResponse×2` `MessageSearchResult×2` `ReactionUser` `User×8` | — | | type-only | adapter | — | response types only; service owns the calls |
| `api/dm_handler.go` | `DB` `DMChannelInfo` `DMUser×2` `User×8` | `NewDMChannelInfo()` `StatusForViewer()` | — | calls | adapter | — | DM response types + pure status helpers |
| `api/emoji_handler.go` | `DB` `Emoji×3` `User×2` | | — | type-only | adapter | — | Emoji/User types only |
| `api/gif_handler.go` | `DB` | — | — | type-only | adapter | — | handle in the signature, unused for calls |
| `api/invite_handler.go` | `DB` `Invite` `User×2` | — | — | type-only | adapter | — | Invite/User types only |
| `api/middleware.go` | `DB` `Role` `Session` `User` | — | `DeleteSession` `TouchAPIToken` `TouchSession` | calls | move | auth | session/API-token touch and revoke |
| `api/plugins_handler.go` | `Auditor×2` | `WriteAudit()` | | calls | adapter | | db.Auditor is the seam; WriteAudit only |
| `api/profile_handler.go` | `DB×2` `Session×2` `User×7` | — | `CreateAttachment` | calls | move | upload | avatar upload creates the attachment row |
| `api/router.go` | `DB×4` | — | `PingRead` `SQLDb` | calls | boundary | — | health probe (PingRead, SQLDb); hub construction left in B3-3 |
| `api/upload_handler.go` | `AttachmentAccess×2` `DB×5` `Role` `User×3` | — | `CreateAttachment` `GetAttachmentWithChannel` `IsAvatarFileURL` `IsDMParticipant` `QueryRowContext` | calls | move | upload | attachment access + a raw QueryRowContext |
| `auth/helpers.go` | `User` | — | | type-only | adapter | | db.User type in a helper signature |
| `auth/resolve.go` | `APIToken` `Role×2` `Session×2` `User×2` | — | — | type-only | adapter | — | Session/APIToken/Role/User types; resolution is injected |
| `cmd/gendocs/main.go` | `DB×3` | `Migrate()` `Open()` | `Close×2` `QueryContext×2` | calls | boundary | — | docs generator migrates its own in-memory catalog |
| `cmd/seed/main.go` | `DB×6` | `Migrate()` `Open()` | `Close` `CreateChannel` `CreateMessage×2` `CreateUser` `GetOrCreateDMChannel` `GetUserByUsername` `ListChannels` `QueryRowContext` | calls | boundary | — | developer seeding tool owns its handle |
| `cmd/seed/profile_alpha.go` | `DB×3` | `Migrate()` | `BeginTx` `ExecContext×2` `QueryRowContext` | calls | boundary | — | the alpha profile writes through the handle main.go owns |
| `internal/app/app.go` | `AuditWriter` `DB` | — | | type-only | boundary | — | the App holds the handle for its lifetime; no calls |
| `internal/app/database.go` | `DB×2` | `Migrate()` `OpenWithMaxReaders()` | `ClearAllVoiceStates` `ResetAllUserStatuses` | calls | boundary | — | opens the handle, migrates, clears stale state at boot |
| `internal/app/hub.go` | `DB` | — | — | type-only | boundary | — | hands the handle to the hub and the service layer it builds |
| `internal/app/maintenance.go` | `DB×3` | — | `DeleteExpiredSessions` `DeleteOrphanedAttachments` | calls | boundary | — | periodic worker: expired sessions, backups, orphan attachments |
| `internal/app/persistence.go` | `AuditWriter×2` `DB×4` | `ErrNotFound` `NewAuditWriter()` | `GetMaxEventSeq` `GetSetting` `SetAuditWriter` `SetSetting` | calls | boundary | — | event persister, audit writer and the boot seq seed own the handle |
| `internal/app/plugins.go` | `DB` | — | — | type-only | boundary | — | passes the handle to the plugin registry as its store; no calls |
| `plugin/pluginstore.go` | `PluginRow×3` | — | — | type-only | adapter | — | PluginRow type only; the store is injected |
| `token_cli.go` | `DB×3` `User` | `Migrate()` `OpenShared()` `WriteAudit()×3` | `Close` `CreateAPIToken` `GetOwnerUser` `GetUserByUsername` `ListAPITokens` `RevokeAPIToken` `RevokeAPITokenByLabel` | calls | move | auth | API-token CLI duplicates admin/handlers_tokens.go |
| `ws/client.go` | `User×2` | — | — | type-only | adapter | — | db.User type on the connection |
| `ws/deps.go` | `Channel` `DB×5` | — | `GetRoleForUser×3` `IsDMParticipant` | calls | move | channel | role and DM-membership reads behind the hub's deps |
| `ws/event.go` | | `BroadcastStatus()` | | calls | adapter | — | pure BroadcastStatus helper |
| `ws/event_persister.go` | `PersistedEvent×2` | | — | type-only | adapter | — | PersistedEvent type; store is an interface |
| `ws/eventstore.go` | `PersistedEvent×3` | — | — | type-only | adapter | — | PersistedEvent type; store is an interface |
| `ws/handlers.go` | `User` | — | `GetChannel` `GetRoleForUser` `GetSessionWithBanStatus` `IsDMParticipant` | calls | move | channel | channel, role, session-ban and DM reads in command handlers |
| `ws/handlers_chat.go` | — | `NewDMChannelInfo()` | — | calls | adapter | — | pure NewDMChannelInfo helper |
| `ws/hub.go` | `DB` | | — | type-only | boundary | — | Hub state holds the handle the families read through; no calls |
| `ws/hub_broadcast.go` | `Channel×2` `Emoji` `Role` | — | `GetRoleForUser` `GetUserByID` | calls | move | channel | member broadcast payloads read the user and role they announce |
| `ws/hub_options.go` | `DB` | — | | type-only | boundary | — | construction validates and stores the handle; no calls |
| `ws/hub_presence.go` | | `BroadcastStatus()` | — | calls | adapter | — | presence coalescer; pure BroadcastStatus helper and the MemberSummary shape |
| `ws/hub_sweep.go` | `User` | — | `GetAllVoiceStates` `GetChannel` `GetChannelVoiceStates` `GetSessionsWithBanStatusBatch` `LeaveVoiceChannelIfMatch×2` | calls | move | voice | stale-voice sweep reads and leaves |
| `ws/hub_visibility.go` | `Channel×2` `ChannelOverride` `DB` `User` | — | `GetChannel×2` `GetChannelOverridesFor` `GetDMParticipantIDs` `GetRoleByID` `GetUserByID` `GetUserDMChannelIDs` `ListChannels×2` | calls | move | channel | visibility and audience resolution reads channels, overrides, participants, users |
| `ws/messages.go` | `Channel×4` `DMChannelInfo×2` `DMUser×2` `Emoji` `Role×3` `User×2` `VoiceState` | `BroadcastStatus()` `StatusForViewer()` | — | calls | adapter | | wire types + pure status helpers |
| `ws/replay.go` | `DB×2` `PersistedEvent` | — | | type-only | move | connection | reconnect replay selection and delivery; serve.go's row split with its code in B3-5 |
| `ws/serve.go` | `DB×3` | `ConnectStatus()` | `GetRoleByID` `GetUserByID` `UpdateUserStatus` | calls | move | connection | connect/disconnect lifecycle; B3-5 splits it by family first |
| `ws/serve_auth.go` | `DB×2` `User` | `StatusOffline` `WriteAudit()` | `GetRoleByID` `GetSessionByTokenHash` `GetUserByID` `MarkUserDisconnected` | calls | move | auth | handshake auth: session, user and role lookups, connect audit, failed-handshake teardown |
| `ws/serve_pumps.go` | — | `StatusOffline` | `MarkUserDisconnected` | calls | move | user | MarkUserDisconnected on pump exit |
| `ws/serve_ready.go` | `Channel×10` `ChannelOverride×6` `ChannelUnread×2` `DB×7` `DMChannelInfo×4` `MemberSummary×3` `Role×4` `User` `VoiceState×5` | `StatusOffline×3` | `GetAllVoiceStates` `GetChannelOverridesFor` `GetChannelUnreadCounts` `GetRoleByID` `GetUserByID` `GetUserDMChannels` `GetVoiceState` `LeaveVoiceChannelIfMatch` `ListChannels` `ListMembers` `ListRoles` | calls | move | channel | ready snapshot and fresh-connect: channels, overrides, unreads, DMs, members, stale-voice cleanup |
| `ws/voice_join.go` | `Channel×3` `ChannelOverride` `VoiceState×5` | `ErrChannelFull` | `GetChannel×2` `GetChannelOverridesFor` `GetChannelVoiceStates` `GetRoleForUser` `GetVoiceState×6` `JoinVoiceChannel` `JoinVoiceChannelIfCapacity` `LeaveVoiceChannelIfMatch` `SetVoiceServerDeafen` `SetVoiceServerMute` | calls | move | voice | voice state reads and writes |
| `ws/voice_moderation.go` | `Role` `VoiceState×3` | `WriteAudit()` | `CountChannelVoiceUsers` `GetChannel×2` `GetRoleForUser` `GetVoiceState×2` `SetVoiceServerDeafen×2` `SetVoiceServerMute×2` | calls | move | voice | mute/deafen/move persist voice state |
61 files import `db` outside `db/` and `service/` (. 1, admin 16, api 10, auth 2, cmd/gendocs 1, cmd/seed 2, internal/app 6, plugin 1, ws 22); 21 are type-only; 0 unlisted.
Dispositions: adapter 18, boundary 15, move 28. Move targets: auth 7, channel 7, connection 2, role 1, settings-ops 4, upload 2, user 2, voice 3.
59 files import `db` outside `db/` and `service/` (. 1, admin 15, api 10, auth 2, cmd/gendocs 1, cmd/seed 2, internal/app 6, plugin 1, ws 21); 21 are type-only; 0 unlisted.
Dispositions: adapter 18, boundary 17, move 24. Move targets: auth 7, channel 7, connection 2, role 1, upload 2, user 2, voice 3.
<!-- dbinventory:end -->
+16 -16
View File
@@ -10,22 +10,22 @@ authority**.
## Active — these drive current work
| Plan | State |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | Approved beta scope, frozen. 57 `BPR-*` requirements. **Amended 2026-08-31:** BPR-032 reworded to the slim single-current-epoch policy (owner decision; the scope HP-2 accepted). |
| [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) | Phase order and gates, B0B10. **B0, B1 and B2 complete** (HP-0, HP-1 and HP-2 all accepted); **B3 in progress** — the B3 plan row below tracks it. B4B10 not started. **Amended 2026-08-28:** dated lines in B3B10, a "Phase execution pattern" section, and a truthful status header. **Amended 2026-08-29:** B3/B7/B9 lines binding the layout-refactor supplement below; current slice updated for B2-2. **Amended 2026-08-31:** B1 exit reworded to identical-tree integration evidence; B6/B8/B10 `server-info` and epoch-window lines aligned with the B2-2 slim decision; B10 gains the BPR-051 comprehension-read row (owner decisions 2026-08-31). |
| [repo-health-issue-register-2026-08-23](repo-health-issue-register-2026-08-23.md) | 116 planning rows. Public-safe; not a replacement for the ledger. **Amended 2026-08-31:** OC-03490375 and OC-0379 enumerated with owner-approved phases; G-03 closure evidence reworded to identical-tree integration evidence; BG-07 re-scoped to BPR-032 as amended. |
| [beta-requirements-traceability-2026-08-23](beta-requirements-traceability-2026-08-23.md) | Requirement → phase → evidence map. No row is release-qualified. |
| [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) | **Supersedes the roadmap's "current evidence snapshot."** B0 measurements and dispositions. |
| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. |
| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. |
| [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. |
| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 complete — HP-2 accepted 2026-08-29.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 (B2-3/B2-4 folded in), B2-5, B2-6, B2-7, B2-9 done 2026-08-29. Scorecard below. |
| [hp-2-scorecard-2026-08-29](hp-2-scorecard-2026-08-29.md) | **HP-2 accepted 2026-08-29.** Seven questions answered with commands; B2 exit gate, nine conditions met (1 at the slim epoch scope, 4 with one E2EE gap disclaimed). Owner follow-ups that do not gate B3: BPR-051 reader line, SEC-01/SEC-04 advisory IDs. |
| [b3-server-architecture-guardrails-2026-08-29](b3-server-architecture-guardrails-2026-08-29.md) | **B3 in progress from 2026-08-29.** Execution plan: B3-0 inventory → B3-1/B3-2 auth slice → HP-3 → lifecycle, hub options, `ws` split, families; guardrails and the alpha dataset beside the slice. B3-0 (inventory + `db-import-boundary` rule) and B3-1 (auth characterization) merged 2026-08-29; B3-2 (auth vertical slice) merged 2026-08-30; B3-9 (five of the six B3-tagged findings; OC-0323 rides B3-8) merged 2026-08-30; HP-3 accepted 2026-08-30; B3-3 (lifecycle extraction into `Server/internal/app/`) merged 2026-08-30 (#1464); B3-4 (hub constructor options) merged 2026-08-31 (#1470); B3-7 (deterministic alpha dataset) merged 2026-08-31 (#1469); B3-5 (`ws` split) in progress — split PR 1 (handshake auth + fresh-connect) merged 2026-08-31 (#1472); split PR 2 (replay family + connection registry) merged 2026-08-31 (#1473); split PR 3 (visibility gather) merged 2026-08-31 (#1474); split PR 4 (voice leftovers + presence coalescer) merged 2026-08-31 (#1475); finisher PR (hub.go < 400 + exit evidence) opened 2026-08-31 — B3-5 complete when it merges; B3-8 families next. |
| [hp-3-scorecard-2026-08-29](hp-3-scorecard-2026-08-29.md) | **HP-3 accepted 2026-08-30 by the owner.** Five questions on the auth vertical slice answered with commands: frozen set green at every pre-squash SHA, `api` db importers 12 → 10, B2 contracts unchanged, the pattern written as D4 in server.md, guardrails as they exist. |
| [b3-bench-baseline-2026-08-30](b3-bench-baseline-2026-08-30.md) | **Recorded 2026-08-30, not gated.** The six B3-6 `Benchmark*` through `benchstat` at `ec8ef24a`, produced by `make bench-baseline`. Nothing in CI reads these numbers; the performance gate is B6's. Regenerating writes a new dated file — replace this row and delete the superseded document, so only the newest baseline is kept. |
| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 16 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
| Plan | State |
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | Approved beta scope, frozen. 57 `BPR-*` requirements. **Amended 2026-08-31:** BPR-032 reworded to the slim single-current-epoch policy (owner decision; the scope HP-2 accepted). |
| [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) | Phase order and gates, B0B10. **B0, B1 and B2 complete** (HP-0, HP-1 and HP-2 all accepted); **B3 in progress** — the B3 plan row below tracks it. B4B10 not started. **Amended 2026-08-28:** dated lines in B3B10, a "Phase execution pattern" section, and a truthful status header. **Amended 2026-08-29:** B3/B7/B9 lines binding the layout-refactor supplement below; current slice updated for B2-2. **Amended 2026-08-31:** B1 exit reworded to identical-tree integration evidence; B6/B8/B10 `server-info` and epoch-window lines aligned with the B2-2 slim decision; B10 gains the BPR-051 comprehension-read row (owner decisions 2026-08-31). |
| [repo-health-issue-register-2026-08-23](repo-health-issue-register-2026-08-23.md) | 116 planning rows. Public-safe; not a replacement for the ledger. **Amended 2026-08-31:** OC-03490375 and OC-0379 enumerated with owner-approved phases; G-03 closure evidence reworded to identical-tree integration evidence; BG-07 re-scoped to BPR-032 as amended. |
| [beta-requirements-traceability-2026-08-23](beta-requirements-traceability-2026-08-23.md) | Requirement → phase → evidence map. No row is release-qualified. |
| [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) | **Supersedes the roadmap's "current evidence snapshot."** B0 measurements and dispositions. |
| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. |
| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. |
| [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. |
| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 complete — HP-2 accepted 2026-08-29.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 (B2-3/B2-4 folded in), B2-5, B2-6, B2-7, B2-9 done 2026-08-29. Scorecard below. |
| [hp-2-scorecard-2026-08-29](hp-2-scorecard-2026-08-29.md) | **HP-2 accepted 2026-08-29.** Seven questions answered with commands; B2 exit gate, nine conditions met (1 at the slim epoch scope, 4 with one E2EE gap disclaimed). Owner follow-ups that do not gate B3: BPR-051 reader line, SEC-01/SEC-04 advisory IDs. |
| [b3-server-architecture-guardrails-2026-08-29](b3-server-architecture-guardrails-2026-08-29.md) | **B3 in progress from 2026-08-29.** Execution plan: B3-0 inventory → B3-1/B3-2 auth slice → HP-3 → lifecycle, hub options, `ws` split, families; guardrails and the alpha dataset beside the slice. B3-0 (inventory + `db-import-boundary` rule) and B3-1 (auth characterization) merged 2026-08-29; B3-2 (auth vertical slice) merged 2026-08-30; B3-9 (five of the six B3-tagged findings; OC-0323 rides B3-8) merged 2026-08-30; HP-3 accepted 2026-08-30; B3-3 (lifecycle extraction into `Server/internal/app/`) merged 2026-08-30 (#1464); B3-4 (hub constructor options) merged 2026-08-31 (#1470); B3-7 (deterministic alpha dataset) merged 2026-08-31 (#1469); B3-5 (`ws` split) in progress — split PR 1 (handshake auth + fresh-connect) merged 2026-08-31 (#1472); split PR 2 (replay family + connection registry) merged 2026-08-31 (#1473); split PR 3 (visibility gather) merged 2026-08-31 (#1474); split PR 4 (voice leftovers + presence coalescer) merged 2026-08-31 (#1475); finisher merged 2026-08-31 (#1476) — **B3-5 complete**, all seven responsibilities and all three size targets; B3-8 in progress — settings/audit family (first of seven) opened 2026-08-31. |
| [hp-3-scorecard-2026-08-29](hp-3-scorecard-2026-08-29.md) | **HP-3 accepted 2026-08-30 by the owner.** Five questions on the auth vertical slice answered with commands: frozen set green at every pre-squash SHA, `api` db importers 12 → 10, B2 contracts unchanged, the pattern written as D4 in server.md, guardrails as they exist. |
| [b3-bench-baseline-2026-08-30](b3-bench-baseline-2026-08-30.md) | **Recorded 2026-08-30, not gated.** The six B3-6 `Benchmark*` through `benchstat` at `ec8ef24a`, produced by `make bench-baseline`. Nothing in CI reads these numbers; the performance gate is B6's. Regenerating writes a new dated file — replace this row and delete the superseded document, so only the newest baseline is kept. |
| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 16 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
## Partially implemented
@@ -1713,6 +1713,47 @@ state (OC-0323 lands here) → admin-UI adapters last (most stay `adapter`).
Each family's evidence block: before/after `db` importer count, allowlist
diff, the family's characterization file.
**Evidence — settings/audit family, 2026-08-31** — branch
`feat/b3-8-settings-family` from `dev` `528ae264` (the B3-5 finisher's
squash). The B3-2 pattern:
- **Characterization**: the admin surface was already pinned —
`admin/api_test.go`'s eleven `TestAdminAPI_*Settings*` rows (GET shape,
PATCH happy path, invalid body, unknown key, mixed keys, every
whitelisted key, empty payload, both require_2fa preconditions, invalid
boolean, the unrelated-key gate) are the family's characterization file
and stayed green untouched through the extraction. The service seam adds
its own: `service/settings_test.go` (nine `TestSettings_*` rows) pins
the same policy at the service plus the service-only contracts
(`ErrNotFound` wrap, audit rows, multi-key apply).
- **Interface/service**: `service.SettingsService` — `List`, `Patch`
(whitelist, boolean normalization, the require_2fa preconditions
including the TOTP census and the unrelated-key guard, atomic apply,
one audit row per changed key), `Setting`. `db.ApplySettings` is the
handler's raw upsert loop as one hand-written transactional wrapper —
the raw SQL left the handler for `db/`, where it belongs. Response
messages are wrapped with `%.0w` so the pinned bodies stay prefix-free.
- **Thin handlers**: `handleGetSettings`/`handlePatchSettings` decode,
delegate and map `ErrBadRequest` → 400; the whitelist copy in
`admin/types.go` is gone. `MaintainBackups` reads `backup_schedule`
and `backup_retention` through the service. The hub's settings cache
consumes a required consumer-side `ws.SettingsReader`
(`HubOptions.Settings`, refusal pinned in
`TestNewHub_RequiredCollaborators`); production wires
`Services.Settings`, the test-hub helpers default it over the test
database.
- **Allowlist diff**: `admin/handlers_settings.go` and
`ws/hub_settings.go` stop importing `db` — both rows deleted (the
B3-5 finisher's import pin is gone with the reads themselves). The
backup pair takes the forecast `boundary` disposition:
`backup_maintenance.go` (backup mechanics only; settings via the
service) and `handlers_backup.go` (VACUUM INTO, WAL checkpoint,
close-and-swap restore own the handle). `settings-ops` disappears
from the move targets.
- **Importer count**: 60 → 59 files import `db` above the domain layer
(admin 16 → 15); dispositions 28/18/15 → 24/18/17 move/adapter/boundary
(tool summary, re-derived).
Exit: every remaining `db` importer above the domain layer is `adapter` or
`boundary` with its reason in `server-boundaries.md`; the exit-gate's "every
direct database use above the domain layer is justified or removed".