diff --git a/Server/admin/admin.go b/Server/admin/admin.go index e7d931ba..81343521 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -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/…", diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 27484353..5250c892 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -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) diff --git a/Server/admin/api.go b/Server/admin/api.go index 93545061..9520774c 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -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) diff --git a/Server/admin/api_edge_cases_test.go b/Server/admin/api_edge_cases_test.go index c5951685..76e3e839 100644 --- a/Server/admin/api_edge_cases_test.go +++ b/Server/admin/api_edge_cases_test.go @@ -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() diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index f081239e..947b5f02 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -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 { diff --git a/Server/admin/audit_coverage_test.go b/Server/admin/audit_coverage_test.go index 46c666a9..a3ab6106 100644 --- a/Server/admin/audit_coverage_test.go +++ b/Server/admin/audit_coverage_test.go @@ -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 { diff --git a/Server/admin/backup_maintenance.go b/Server/admin/backup_maintenance.go index 036c78f9..fef6648b 100644 --- a/Server/admin/backup_maintenance.go +++ b/Server/admin/backup_maintenance.go @@ -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 diff --git a/Server/admin/backup_maintenance_test.go b/Server/admin/backup_maintenance_test.go index 7985b923..fffb6c86 100644 --- a/Server/admin/backup_maintenance_test.go +++ b/Server/admin/backup_maintenance_test.go @@ -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) diff --git a/Server/admin/channels_archive_voice_test.go b/Server/admin/channels_archive_voice_test.go index efa6d261..ae8a308b 100644 --- a/Server/admin/channels_archive_voice_test.go +++ b/Server/admin/channels_archive_voice_test.go @@ -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()) diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 6856f811..872e72ea 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -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" diff --git a/Server/admin/handlers_channel_perms_test.go b/Server/admin/handlers_channel_perms_test.go index ccf59314..3b12e4e9 100644 --- a/Server/admin/handlers_channel_perms_test.go +++ b/Server/admin/handlers_channel_perms_test.go @@ -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) diff --git a/Server/admin/handlers_channel_user_perms_test.go b/Server/admin/handlers_channel_user_perms_test.go index 6c20ede2..effd878f 100644 --- a/Server/admin/handlers_channel_user_perms_test.go +++ b/Server/admin/handlers_channel_user_perms_test.go @@ -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") diff --git a/Server/admin/handlers_channels_test.go b/Server/admin/handlers_channels_test.go index afe131f6..77dea9d6 100644 --- a/Server/admin/handlers_channels_test.go +++ b/Server/admin/handlers_channels_test.go @@ -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") diff --git a/Server/admin/handlers_roles_test.go b/Server/admin/handlers_roles_test.go index f940d694..b1c78e72 100644 --- a/Server/admin/handlers_roles_test.go +++ b/Server/admin/handlers_roles_test.go @@ -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 { diff --git a/Server/admin/handlers_settings.go b/Server/admin/handlers_settings.go index 0c955fde..01a76d55 100644 --- a/Server/admin/handlers_settings.go +++ b/Server/admin/handlers_settings.go @@ -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) } } diff --git a/Server/admin/handlers_users_atomic_test.go b/Server/admin/handlers_users_atomic_test.go index 1a87631d..2fd14940 100644 --- a/Server/admin/handlers_users_atomic_test.go +++ b/Server/admin/handlers_users_atomic_test.go @@ -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 diff --git a/Server/admin/handlers_users_broadcast_test.go b/Server/admin/handlers_users_broadcast_test.go index dde8bcaa..012a8153 100644 --- a/Server/admin/handlers_users_broadcast_test.go +++ b/Server/admin/handlers_users_broadcast_test.go @@ -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) diff --git a/Server/admin/harvest_s5_roles_test.go b/Server/admin/harvest_s5_roles_test.go index 7e9001b7..77a2f447 100644 --- a/Server/admin/harvest_s5_roles_test.go +++ b/Server/admin/harvest_s5_roles_test.go @@ -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 diff --git a/Server/admin/logstream_apitoken_test.go b/Server/admin/logstream_apitoken_test.go index 297a544a..884fddb4 100644 --- a/Server/admin/logstream_apitoken_test.go +++ b/Server/admin/logstream_apitoken_test.go @@ -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) diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index 60c8ca7d..278faf09 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -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() diff --git a/Server/admin/middleware_coverage_test.go b/Server/admin/middleware_coverage_test.go index add99cd5..51811321 100644 --- a/Server/admin/middleware_coverage_test.go +++ b/Server/admin/middleware_coverage_test.go @@ -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) diff --git a/Server/admin/middleware_db_error_test.go b/Server/admin/middleware_db_error_test.go index 0358d0d0..2de7d9c7 100644 --- a/Server/admin/middleware_db_error_test.go +++ b/Server/admin/middleware_db_error_test.go @@ -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) diff --git a/Server/admin/perm_gates_test.go b/Server/admin/perm_gates_test.go index d5557b3a..1eecb0f6 100644 --- a/Server/admin/perm_gates_test.go +++ b/Server/admin/perm_gates_test.go @@ -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) diff --git a/Server/admin/restart_guard_test.go b/Server/admin/restart_guard_test.go index 3ad2f20c..fed727b6 100644 --- a/Server/admin/restart_guard_test.go +++ b/Server/admin/restart_guard_test.go @@ -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") diff --git a/Server/admin/setup_clientip_test.go b/Server/admin/setup_clientip_test.go index ee372344..febc1940 100644 --- a/Server/admin/setup_clientip_test.go +++ b/Server/admin/setup_clientip_test.go @@ -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{ diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index c1a7d06d..21c099b7 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -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 { diff --git a/Server/admin/setup_limiter_reap_test.go b/Server/admin/setup_limiter_reap_test.go index b6543f72..49c012ce 100644 --- a/Server/admin/setup_limiter_reap_test.go +++ b/Server/admin/setup_limiter_reap_test.go @@ -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") diff --git a/Server/admin/setup_owner_orphan_test.go b/Server/admin/setup_owner_orphan_test.go index 0ffbe146..33cca568 100644 --- a/Server/admin/setup_owner_orphan_test.go +++ b/Server/admin/setup_owner_orphan_test.go @@ -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", diff --git a/Server/admin/setup_wizard_test.go b/Server/admin/setup_wizard_test.go index f6985f58..24cf1bc0 100644 --- a/Server/admin/setup_wizard_test.go +++ b/Server/admin/setup_wizard_test.go @@ -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(), diff --git a/Server/admin/types.go b/Server/admin/types.go index 8dce0c4d..4a6cc2b1 100644 --- a/Server/admin/types.go +++ b/Server/admin/types.go @@ -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. diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index 45c9751d..72267056 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -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) diff --git a/Server/api/livekit_proxy_ws_test.go b/Server/api/livekit_proxy_ws_test.go index 4e65cd7c..7e327c0a 100644 --- a/Server/api/livekit_proxy_ws_test.go +++ b/Server/api/livekit_proxy_ws_test.go @@ -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) } diff --git a/Server/api/router.go b/Server/api/router.go index 7ac08ed2..b2f9ba88 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -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)) diff --git a/Server/coverage-floor.json b/Server/coverage-floor.json index b56db817..f6a2b4c9 100644 --- a/Server/coverage-floor.json +++ b/Server/coverage-floor.json @@ -6,7 +6,7 @@ "auth": 90.8, "db": 79.3, "permissions": 100.0, - "service": 67.8, + "service": 69.2, "ws": 86.7 } } diff --git a/Server/db/admin_queries.go b/Server/db/admin_queries.go index ed7b62f1..13819da0 100644 --- a/Server/db/admin_queries.go +++ b/Server/db/admin_queries.go @@ -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) diff --git a/Server/db/settings_apply_test.go b/Server/db/settings_apply_test.go new file mode 100644 index 00000000..8aea46e7 --- /dev/null +++ b/Server/db/settings_apply_test.go @@ -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") + } +} diff --git a/Server/internal/app/hub.go b/Server/internal/app/hub.go index 8bb5a11e..3febf383 100644 --- a/Server/internal/app/hub.go +++ b/Server/internal/app/hub.go @@ -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, diff --git a/Server/internal/app/lifecycle.go b/Server/internal/app/lifecycle.go index 73a5f23e..5ebc051e 100644 --- a/Server/internal/app/lifecycle.go +++ b/Server/internal/app/lifecycle.go @@ -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 diff --git a/Server/internal/app/lifecycle_test.go b/Server/internal/app/lifecycle_test.go index 9ce17482..3808c4c9 100644 --- a/Server/internal/app/lifecycle_test.go +++ b/Server/internal/app/lifecycle_test.go @@ -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) } diff --git a/Server/internal/app/maintenance.go b/Server/internal/app/maintenance.go index 5ff102ae..e9d802cb 100644 --- a/Server/internal/app/maintenance.go +++ b/Server/internal/app/maintenance.go @@ -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 } diff --git a/Server/invariants/db_import_boundary.go b/Server/invariants/db_import_boundary.go index 34e0688b..d0334d6e 100644 --- a/Server/invariants/db_import_boundary.go +++ b/Server/invariants/db_import_boundary.go @@ -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"}, diff --git a/Server/service/datastore.go b/Server/service/datastore.go index e06057ed..3163285e 100644 --- a/Server/service/datastore.go +++ b/Server/service/datastore.go @@ -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 } diff --git a/Server/service/service.go b/Server/service/service.go index 33bebc6f..7620b0ed 100644 --- a/Server/service/service.go +++ b/Server/service/service.go @@ -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), } } diff --git a/Server/service/settings.go b/Server/service/settings.go new file mode 100644 index 00000000..9a5f7592 --- /dev/null +++ b/Server/service/settings.go @@ -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) + } +} diff --git a/Server/service/settings_test.go b/Server/service/settings_test.go new file mode 100644 index 00000000..8575f3ee --- /dev/null +++ b/Server/service/settings_test.go @@ -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) + } +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 6d868c9d..069dd974 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -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 diff --git a/Server/ws/hub_options.go b/Server/ws/hub_options.go index 0a8b6a98..059c0e0e 100644 --- a/Server/ws/hub_options.go +++ b/Server/ws/hub_options.go @@ -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{}), diff --git a/Server/ws/hub_settings.go b/Server/ws/hub_settings.go index f198312d..0efc376d 100644 --- a/Server/ws/hub_settings.go +++ b/Server/ws/hub_settings.go @@ -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() diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index 44f46133..8a0c79ae 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -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") } } diff --git a/Server/ws/testhub_ext_test.go b/Server/ws/testhub_ext_test.go index 7066b6a2..a01155ba 100644 --- a/Server/ws/testhub_ext_test.go +++ b/Server/ws/testhub_ext_test.go @@ -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) diff --git a/Server/ws/testhub_internal_test.go b/Server/ws/testhub_internal_test.go index 9ee53684..9ebcb473 100644 --- a/Server/ws/testhub_internal_test.go +++ b/Server/ws/testhub_internal_test.go @@ -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) diff --git a/docs/architecture/server-boundaries.md b/docs/architecture/server-boundaries.md index f2fb3a2c..a8bec7dd 100644 --- a/docs/architecture/server-boundaries.md +++ b/docs/architecture/server-boundaries.md @@ -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. -| 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. diff --git a/docs/plans/README.md b/docs/plans/README.md index d54ced27..cb68392f 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -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, B0–B10. **B0, B1 and B2 complete** (HP-0, HP-1 and HP-2 all accepted); **B3 in progress** — the B3 plan row below tracks it. B4–B10 not started. **Amended 2026-08-28:** dated lines in B3–B10, 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-0349–0375 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 1–6 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, B0–B10. **B0, B1 and B2 complete** (HP-0, HP-1 and HP-2 all accepted); **B3 in progress** — the B3 plan row below tracks it. B4–B10 not started. **Amended 2026-08-28:** dated lines in B3–B10, 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-0349–0375 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 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. | ## Partially implemented diff --git a/docs/plans/b3-server-architecture-guardrails-2026-08-29.md b/docs/plans/b3-server-architecture-guardrails-2026-08-29.md index 9b44baa4..458b4c72 100644 --- a/docs/plans/b3-server-architecture-guardrails-2026-08-29.md +++ b/docs/plans/b3-server-architecture-guardrails-2026-08-29.md @@ -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".