diff --git a/Server/.golangci.yml b/Server/.golangci.yml index f4b5bfe0..21c5df82 100644 --- a/Server/.golangci.yml +++ b/Server/.golangci.yml @@ -29,6 +29,7 @@ linters: excludes: - G104 # unhandled errors — errcheck covers this better - G304 # file path from variable — expected in file storage code + - G306 # WriteFile perms ≤0600 — our only hits are generated source files (genprotocol), which must stay world-readable or multi-stage container builds break - G706 # log injection — false positive with slog structured logging (values are typed key-value pairs, not interpolated) exclusions: diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 2772100d..5c6e884d 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "net/http" "net/http/httptest" "os" @@ -159,9 +160,9 @@ func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Create admin user (role_id=2, position=80) - adminUID, _ := database.CreateUser("middlewareadmin", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "middlewareadmin", "hash", 2) token := "mw-admin-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) diff --git a/Server/admin/api_edge_cases_test.go b/Server/admin/api_edge_cases_test.go index 2060eac4..d606e1fc 100644 --- a/Server/admin/api_edge_cases_test.go +++ b/Server/admin/api_edge_cases_test.go @@ -41,8 +41,8 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { token := createAdminUser(t, database) // Create and ban a target user first. - targetUID, _ := database.CreateUser("unbanme", "hash", 3) - _ = database.BanUser(targetUID, "test ban", nil) + targetUID, _ := database.CreateUser(context.Background(), "unbanme", "hash", 3) + _ = database.BanUser(context.Background(), targetUID, "test ban", nil) body := map[string]any{"banned": false} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -52,7 +52,7 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { } // Verify the user is now unbanned. - user, _ := database.GetUserByID(targetUID) + user, _ := database.GetUserByID(context.Background(), targetUID) if user.Banned { t.Error("user is still banned after unban request") } @@ -64,7 +64,7 @@ func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("invalidbody", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "invalidbody", "hash", 3) req := httptest.NewRequest(http.MethodPatch, "/users/"+itoa(targetUID), bytes.NewReader([]byte("not-json"))) req.Header.Set("Authorization", "Bearer "+token) @@ -147,7 +147,7 @@ func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("malformed", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "malformed", "text", "", "", 0) req := httptest.NewRequest(http.MethodPatch, "/channels/"+itoa(chID), bytes.NewReader([]byte("not-json"))) req.Header.Set("Authorization", "Bearer "+token) @@ -235,9 +235,9 @@ func TestAdminAPI_AuditLog_Pagination(t *testing.T) { token := createAdminUser(t, database) // Create several audit entries. - uid, _ := database.CreateUser("auditpager", "hash", 1) + uid, _ := database.CreateUser(context.Background(), "auditpager", "hash", 1) for i := 0; i < 5; i++ { - _ = database.LogAudit(uid, "TEST", "test", int64(i), "") + _ = database.LogAudit(context.Background(), uid, "TEST", "test", int64(i), "") } // Fetch page 2 with limit=2, offset=2 — should return 2 entries. @@ -324,7 +324,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("ban-nohub", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "ban-nohub", "hash", 3) body := map[string]any{"banned": true, "ban_reason": "nil hub test"} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -334,7 +334,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { } // Verify ban was still applied despite nil hub. - user, _ := database.GetUserByID(targetUID) + user, _ := database.GetUserByID(context.Background(), targetUID) if !user.Banned { t.Error("user should be banned even with nil hub") } @@ -363,7 +363,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { if payload.Ticket == "" { t.Fatal("expected non-empty log stream ticket") } - if err := database.DeleteSession(auth.HashToken(token)); err != nil { + if err := database.DeleteSession(context.Background(), auth.HashToken(token)); err != nil { t.Fatalf("DeleteSession: %v", err) } @@ -412,7 +412,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { t.Fatalf("legacy token stream status = %d, want 401; body: %s", legacyResp.StatusCode, string(body)) } - if _, err := database.CreateSession(1, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), 1, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } ticketResp = doRequest(t, handler, http.MethodPost, "/logs/ticket", token, nil) @@ -422,7 +422,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) { if err := json.Unmarshal(ticketResp.Body.Bytes(), &payload); err != nil { t.Fatalf("unmarshal restored ticket response: %v", err) } - if err := database.UpdateUserRole(1, 3); err != nil { + if err := database.UpdateUserRole(context.Background(), 1, 3); err != nil { t.Fatalf("UpdateUserRole: %v", err) } demotedResp, err := http.Get(srv.URL + "/logs/stream?ticket=" + payload.Ticket) @@ -444,7 +444,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("role-nohub", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "role-nohub", "hash", 3) body := map[string]any{"role_id": float64(2)} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -454,7 +454,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { } // Verify role was still changed despite nil hub. - user, _ := database.GetUserByID(targetUID) + user, _ := database.GetUserByID(context.Background(), targetUID) if user.RoleID != 2 { t.Errorf("RoleID = %d, want 2", user.RoleID) } @@ -469,7 +469,7 @@ func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("banwithout", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "banwithout", "hash", 3) // No ban_reason in body — the nil check in handlePatchUser uses empty string. body := map[string]any{"banned": true} @@ -490,7 +490,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("rolebroadcast", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "rolebroadcast", "hash", 3) body := map[string]any{"role_id": float64(2)} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -531,7 +531,7 @@ func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) - _, _ = database.CreateUser("existing", "hash", 1) + _, _ = database.CreateUser(context.Background(), "existing", "hash", 1) w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil) @@ -583,7 +583,7 @@ func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) - _, _ = database.CreateUser("existing", "hash", 1) + _, _ = database.CreateUser(context.Background(), "existing", "hash", 1) body := map[string]string{ "username": "hacker", diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index a8293658..db67ef68 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -2,6 +2,7 @@ package admin_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -160,14 +161,14 @@ func openAdminTestDB(t *testing.T) *db.DB { func createAdminUser(t *testing.T, database *db.DB) string { t.Helper() // Owner role has permissions = 2147483647 (includes ADMINISTRATOR bit 0x40000000) - uid, err := database.CreateUser("adminuser", "$2a$12$placeholder", 1) + uid, err := database.CreateUser(context.Background(), "adminuser", "$2a$12$placeholder", 1) if err != nil { t.Fatalf("CreateUser admin: %v", err) } token := "test-admin-token-" + t.Name() tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } return token @@ -177,14 +178,14 @@ func createAdminUser(t *testing.T, database *db.DB) string { func createMemberUser(t *testing.T, database *db.DB) string { t.Helper() // Member role (id=3) has limited permissions, not ADMINISTRATOR - uid, err := database.CreateUser("memberuser", "$2a$12$placeholder", 3) + uid, err := database.CreateUser(context.Background(), "memberuser", "$2a$12$placeholder", 3) if err != nil { t.Fatalf("CreateUser member: %v", err) } token := "test-member-token-" + t.Name() tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } return token @@ -322,7 +323,7 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { ownerToken := createAdminUser(t, database) // Owner role (pos 100) // A second owner-rank user: equal position, cannot be banned. - peerUID, err := database.CreateUser("peerowner", "$2a$12$placeholder", 1) + peerUID, err := database.CreateUser(context.Background(), "peerowner", "$2a$12$placeholder", 1) if err != nil { t.Fatalf("CreateUser peerowner: %v", err) } @@ -331,27 +332,27 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { if w.Code != http.StatusForbidden { t.Fatalf("equal-rank ban: status = %d, want 403; body: %s", w.Code, w.Body.String()) } - if u, _ := database.GetUserByID(peerUID); u.Banned { + if u, _ := database.GetUserByID(context.Background(), peerUID); u.Banned { t.Fatal("equal-rank target must not be banned") } // A lower-positioned role that still holds ADMINISTRATOR (panel access): // its holder must not be able to ban the higher-ranked owner. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO roles (id, name, permissions, position, is_default) VALUES (9, 'JuniorAdmin', ?, 50, 0)`, permissions.Administrator, ); err != nil { t.Fatalf("inserting junior admin role: %v", err) } - juniorUID, err := database.CreateUser("junioradmin", "$2a$12$placeholder", 9) + juniorUID, err := database.CreateUser(context.Background(), "junioradmin", "$2a$12$placeholder", 9) if err != nil { t.Fatalf("CreateUser junioradmin: %v", err) } juniorToken := "junior-token-" + t.Name() - if _, err := database.CreateSession(juniorUID, auth.HashToken(juniorToken), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), juniorUID, auth.HashToken(juniorToken), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession junior: %v", err) } - ownerUser, err := database.GetUserByUsername("adminuser") + ownerUser, err := database.GetUserByUsername(context.Background(), "adminuser") if err != nil || ownerUser == nil { t.Fatalf("GetUserByUsername adminuser: %v", err) } @@ -360,12 +361,12 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { if w.Code != http.StatusForbidden { t.Fatalf("junior bans owner: status = %d, want 403; body: %s", w.Code, w.Body.String()) } - if u, _ := database.GetUserByID(ownerUser.ID); u.Banned { + if u, _ := database.GetUserByID(context.Background(), ownerUser.ID); u.Banned { t.Fatal("owner must not be banned by a lower rank") } // Downward ban still works: junior admin (pos 50) bans a member (pos 40). - memberUID, err := database.CreateUser("banme", "$2a$12$placeholder", 3) + memberUID, err := database.CreateUser(context.Background(), "banme", "$2a$12$placeholder", 3) if err != nil { t.Fatalf("CreateUser banme: %v", err) } @@ -374,7 +375,7 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("junior bans member: status = %d, want 200; body: %s", w.Code, w.Body.String()) } - if u, _ := database.GetUserByID(memberUID); !u.Banned { + if u, _ := database.GetUserByID(context.Background(), memberUID); !u.Banned { t.Fatal("member should be banned by higher-ranked actor") } } @@ -385,7 +386,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) { token := createAdminUser(t, database) // Create a target user - targetUID, _ := database.CreateUser("target", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "target", "hash", 3) body := map[string]any{ "banned": true, @@ -398,7 +399,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) { } // Verify user is banned in DB - user, err := database.GetUserByID(targetUID) + user, err := database.GetUserByID(context.Background(), targetUID) if err != nil { t.Fatalf("GetUserByID: %v", err) } @@ -412,7 +413,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("rolechange", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "rolechange", "hash", 3) body := map[string]any{ "role_id": float64(2), @@ -423,7 +424,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - user, _ := database.GetUserByID(targetUID) + user, _ := database.GetUserByID(context.Background(), targetUID) if user.RoleID != 2 { t.Errorf("RoleID = %d, want 2", user.RoleID) } @@ -461,8 +462,8 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("logoutme", "hash", 3) - _, _ = database.CreateSession(targetUID, "victim-token-hash", "web", "1.2.3.4") + targetUID, _ := database.CreateUser(context.Background(), "logoutme", "hash", 3) + _, _ = database.CreateSession(context.Background(), targetUID, "victim-token-hash", "web", "1.2.3.4") w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil) @@ -470,7 +471,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) { t.Errorf("status = %d, want 204", w.Code) } - sessions, _ := database.GetUserSessions(targetUID) + sessions, _ := database.GetUserSessions(context.Background(), targetUID) if len(sessions) != 0 { t.Errorf("expected 0 sessions after force logout, got %d", len(sessions)) } @@ -494,7 +495,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - _, _ = database.AdminCreateChannel("general", "text", "", "", 0) + _, _ = database.AdminCreateChannel(context.Background(), "general", "text", "", "", 0) w := doRequest(t, handler, http.MethodGet, "/channels", token, nil) @@ -562,7 +563,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("old", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "old", "text", "", "", 0) body := map[string]any{ "name": "updated", @@ -598,7 +599,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("del-me", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "del-me", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) @@ -626,8 +627,8 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - uid, _ := database.CreateUser("actor", "hash", 1) - _ = database.LogAudit(uid, "TEST_ACTION", "user", uid, "detail") + uid, _ := database.CreateUser(context.Background(), "actor", "hash", 1) + _ = database.LogAudit(context.Background(), uid, "TEST_ACTION", "user", uid, "detail") w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=10&offset=0", token, nil) @@ -702,7 +703,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) { } // Verify the change was persisted - val, err := database.GetSetting("server_name") + val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting: %v", err) } @@ -733,9 +734,9 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Admin (role 2) can authenticate but is not Owner (role 1, position 100) - adminUID, _ := database.CreateUser("adminonly", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) token := "admin-only-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) @@ -768,7 +769,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { token := createAdminUser(t, database) // Create a target user to act on. - targetUID, _ := database.CreateUser("ctxtarget", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "ctxtarget", "hash", 3) body := map[string]any{"banned": true, "ban_reason": "context test"} w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body) @@ -779,7 +780,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { // The audit log should have a non-zero actor_id showing the actor was // resolved (not 0, which would indicate a failed context lookup). - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog: %v", err) } @@ -801,8 +802,8 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("logoutctx", "hash", 3) - _, _ = database.CreateSession(targetUID, "victim-hash-ctx", "web", "1.2.3.4") + targetUID, _ := database.CreateUser(context.Background(), "logoutctx", "hash", 3) + _, _ = database.CreateSession(context.Background(), targetUID, "victim-hash-ctx", "web", "1.2.3.4") w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil) @@ -810,7 +811,7 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) } - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog: %v", err) } @@ -870,7 +871,7 @@ func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) { } // The valid key must NOT have been written because the request was rejected. - val, err := database.GetSetting("server_name") + val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting: %v", err) } @@ -953,7 +954,7 @@ func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrat handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil { t.Fatalf("enroll admin user: %v", err) } @@ -993,7 +994,7 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { token := createAdminUser(t, database) // Create a second user so the list is non-trivial. - _, _ = database.CreateUser("plainuser", "supersecretbcrypthash", 3) + _, _ = database.CreateUser(context.Background(), "plainuser", "supersecretbcrypthash", 3) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -1067,7 +1068,7 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("patchvictim", "topsecretbcrypt", 3) + targetUID, _ := database.CreateUser(context.Background(), "patchvictim", "topsecretbcrypt", 3) body := map[string]any{ "banned": true, @@ -1095,7 +1096,7 @@ func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - targetUID, _ := database.CreateUser("patchtotp", "hash", 3) + targetUID, _ := database.CreateUser(context.Background(), "patchtotp", "hash", 3) w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]any{ "banned": false, @@ -1210,7 +1211,7 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("before", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "before", "text", "", "", 0) body := map[string]any{"name": "after"} w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) @@ -1231,7 +1232,7 @@ func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("patchme", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "patchme", "text", "", "", 0) body := map[string]any{"name": "patched"} w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body) @@ -1246,7 +1247,7 @@ func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("delete-me", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "delete-me", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) @@ -1266,7 +1267,7 @@ func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, _ := database.AdminCreateChannel("del-no-hub", "text", "", "", 0) + chID, _ := database.AdminCreateChannel(context.Background(), "del-no-hub", "text", "", "", 0) w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil) if w.Code != http.StatusNoContent { diff --git a/Server/admin/handlers_backup.go b/Server/admin/handlers_backup.go index 888286df..2a287def 100644 --- a/Server/admin/handlers_backup.go +++ b/Server/admin/handlers_backup.go @@ -1,6 +1,7 @@ package admin import ( + "context" "fmt" "io" "log/slog" @@ -41,7 +42,10 @@ func handleBackup(database *db.DB) http.Handler { timestamp := time.Now().UTC().Format("20060102_150405") backupPath := filepath.Join(backupDir, "chatserver_"+timestamp+".db") - if err := database.BackupTo(backupPath); err != nil { + // Detached like the restore path's safety backup: an interrupted + // VACUUM INTO leaves a truncated .db that handleListBackups would + // present as restorable. + if err := database.BackupTo(context.WithoutCancel(r.Context()), backupPath); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed") return } @@ -49,7 +53,7 @@ func handleBackup(database *db.DB) http.Handler { actor := actorFromContext(r) backupName := filepath.Base(backupPath) slog.Info("database backup created", "actor_id", actor, "name", backupName) - db.WriteAudit(database, actor, "backup_create", "server", 0, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "backup_create", "server", 0, fmt.Sprintf("backup saved: %s", backupName)) writeJSON(w, http.StatusOK, map[string]string{ @@ -133,7 +137,7 @@ func handleDeleteBackup(database *db.DB) http.Handler { actor := actorFromContext(r) slog.Info("backup deleted", "actor_id", actor, "name", name) - db.WriteAudit(database, actor, "backup_delete", "server", 0, "deleted backup "+name) + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "backup_delete", "server", 0, "deleted backup "+name) w.WriteHeader(http.StatusNoContent) }) @@ -160,9 +164,12 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { dbPath := filepath.Join("data", "chatserver.db") - // Safety: create a pre-restore backup before overwriting. + // Safety: create a pre-restore backup before overwriting. WithoutCancel: + // the restore proceeds regardless of client disconnect (Close/copyFile + // below are not ctx-aware), so the safety backup must not be skippable + // by a canceled request ctx. preRestore := filepath.Join("data", "backups", "pre_restore_"+time.Now().UTC().Format("20060102_150405")+".db") - if err := database.BackupTo(preRestore); err != nil { + if err := database.BackupTo(context.WithoutCancel(r.Context()), preRestore); err != nil { slog.Warn("pre-restore backup failed", "err", err) } @@ -171,7 +178,7 @@ func handleRestoreBackup(database *db.DB, hub HubBroadcaster) http.Handler { // Checkpoint the WAL and close the database connection before overwriting // to prevent corruption from concurrent writes (BUG-096). - if _, checkpointErr := database.SQLDb().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); checkpointErr != nil { + if _, checkpointErr := database.SQLDb().ExecContext(context.WithoutCancel(r.Context()), "PRAGMA wal_checkpoint(TRUNCATE)"); checkpointErr != nil { slog.Warn("pre-restore WAL checkpoint failed", "err", checkpointErr) } diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 86299f41..4a2cf1c2 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "net/http" "os" @@ -77,9 +78,9 @@ func TestHandleBackup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - adminUID, _ := database.CreateUser("backupadmin", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "backupadmin", "hash", 2) token := "backup-admin-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) @@ -226,9 +227,9 @@ func TestHandleDeleteBackup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - adminUID, _ := database.CreateUser("deladmin", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "deladmin", "hash", 2) token := "del-admin-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") // Create the file so path validation doesn't return 404 before the 403. backupDir := filepath.Join(tmpDir, "data", "backups") @@ -354,9 +355,9 @@ func TestHandleRestoreBackup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) - adminUID, _ := database.CreateUser("restoreadmin", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "restoreadmin", "hash", 2) token := "restore-admin-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") // Create files so path checks pass before auth check. backupDir := filepath.Join(tmpDir, "data", "backups") diff --git a/Server/admin/handlers_channel_perms.go b/Server/admin/handlers_channel_perms.go index 9ae893db..83daf7ba 100644 --- a/Server/admin/handlers_channel_perms.go +++ b/Server/admin/handlers_channel_perms.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "fmt" "log/slog" @@ -27,7 +28,7 @@ func getPermChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id") return nil } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") return nil @@ -55,7 +56,7 @@ func handleGetChannelPermissions(database *db.DB) http.HandlerFunc { if ch == nil { return } - overrides, err := database.ListChannelRoleOverrides(ch.ID) + overrides, err := database.ListChannelRoleOverrides(r.Context(), ch.ID) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channel permissions") return @@ -81,7 +82,7 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") return } - role, err := database.GetRoleByID(roleID) + role, err := database.GetRoleByID(r.Context(), roleID) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch role") return @@ -100,7 +101,7 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid allow := req.Allow & permissions.AllPerms deny := req.Deny & permissions.AllPerms - if err := database.UpsertChannelOverride(ch.ID, roleID, allow, deny); err != nil { + if err := database.UpsertChannelOverride(r.Context(), ch.ID, roleID, allow, deny); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save channel permission") return } @@ -108,7 +109,7 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid actor := actorFromContext(r) slog.Info("channel permissions updated", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID, "allow", allow, "deny", deny) - db.WriteAudit(database, actor, "channel_perms_update", "channel", ch.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_perms_update", "channel", ch.ID, fmt.Sprintf("set overrides for role %s on #%s (allow=%#x deny=%#x)", role.Name, ch.Name, allow, deny)) if permInvalidator != nil { @@ -140,14 +141,14 @@ func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInva return } - if err := database.DeleteChannelOverride(ch.ID, roleID); err != nil { + if err := database.DeleteChannelOverride(r.Context(), ch.ID, roleID); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel permission") return } actor := actorFromContext(r) slog.Info("channel permissions cleared", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID) - db.WriteAudit(database, actor, "channel_perms_clear", "channel", ch.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_perms_clear", "channel", ch.ID, fmt.Sprintf("cleared overrides for role %d on #%s", roleID, ch.Name)) if permInvalidator != nil { diff --git a/Server/admin/handlers_channel_perms_test.go b/Server/admin/handlers_channel_perms_test.go index dc48095d..449ed264 100644 --- a/Server/admin/handlers_channel_perms_test.go +++ b/Server/admin/handlers_channel_perms_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "net/http" "testing" @@ -31,7 +32,7 @@ func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -80,7 +81,7 @@ func TestGetChannelPermissions_DMRejected(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("dm-chan", "dm", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0) if err != nil { t.Fatalf("CreateChannel dm: %v", err) } @@ -101,7 +102,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -114,7 +115,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - allow, deny, err := database.GetChannelPermissions(chID, 3) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 3) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -129,7 +130,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { t.Errorf("RefreshChannelVisibility not called for channel %d", chID) } - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog: %v", err) } @@ -149,7 +150,7 @@ func TestPutChannelPermission_MasksUnknownBits(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret2", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret2", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -162,7 +163,7 @@ func TestPutChannelPermission_MasksUnknownBits(t *testing.T) { t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) } - allow, deny, err := database.GetChannelPermissions(chID, 3) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 3) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -179,7 +180,7 @@ func TestPutChannelPermission_UnknownRole(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret3", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret3", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -197,7 +198,7 @@ func TestPutChannelPermission_NonAdminForbidden(t *testing.T) { _ = createAdminUser(t, database) memberToken := createMemberUser(t, database) - chID, err := database.CreateChannel("secret4", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret4", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -218,11 +219,11 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) token := createAdminUser(t, database) - chID, err := database.CreateChannel("secret5", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "secret5", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - if err := database.UpsertChannelOverride(chID, 3, 0, permissions.ReadMessages); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 3, 0, permissions.ReadMessages); err != nil { t.Fatalf("UpsertChannelOverride: %v", err) } @@ -232,7 +233,7 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) { t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) } - allow, deny, err := database.GetChannelPermissions(chID, 3) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 3) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } diff --git a/Server/admin/handlers_channels.go b/Server/admin/handlers_channels.go index 9b96978a..120634c5 100644 --- a/Server/admin/handlers_channels.go +++ b/Server/admin/handlers_channels.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "fmt" "log/slog" @@ -63,7 +64,7 @@ func validateCategoryType(channelType, category string) string { func handleListChannels(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - channels, err := database.ListChannels() + channels, err := database.ListChannels(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channels") return @@ -102,20 +103,20 @@ func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - id, err := database.AdminCreateChannel(req.Name, req.Type, req.Category, req.Topic, req.Position) + id, err := database.AdminCreateChannel(r.Context(), req.Name, req.Type, req.Category, req.Topic, req.Position) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create channel") return } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(r.Context(), id) if err != nil || ch == nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel") return } actor := actorFromContext(r) slog.Info("channel created", "actor_id", actor, "channel", req.Name, "type", req.Type) - db.WriteAudit(database, actor, "channel_create", "channel", id, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_create", "channel", id, fmt.Sprintf("created #%s (%s)", req.Name, req.Type)) if hub != nil { hub.BroadcastChannelCreate(ch) @@ -141,7 +142,7 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - existing, err := database.GetChannel(id) + existing, err := database.GetChannel(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") return @@ -164,17 +165,17 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - if err := database.AdminUpdateChannel(id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil { + if err := database.AdminUpdateChannel(r.Context(), id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update channel") return } actor := actorFromContext(r) slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name) - db.WriteAudit(database, actor, "channel_update", "channel", id, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_update", "channel", id, fmt.Sprintf("updated #%s", req.Name)) - updated, err := database.GetChannel(id) + updated, err := database.GetChannel(r.Context(), id) if err != nil || updated == nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated channel") return @@ -194,7 +195,7 @@ func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - existing, err := database.GetChannel(id) + existing, err := database.GetChannel(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") return @@ -204,13 +205,13 @@ func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return } - if err := database.AdminDeleteChannel(id); err != nil { + if err := database.AdminDeleteChannel(r.Context(), id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel") return } actor := actorFromContext(r) slog.Warn("channel deleted", "actor_id", actor, "channel_id", id, "name", existing.Name) - db.WriteAudit(database, actor, "channel_delete", "channel", id, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_delete", "channel", id, fmt.Sprintf("deleted #%s", existing.Name)) if hub != nil { hub.BroadcastChannelDelete(id) @@ -224,7 +225,7 @@ func handleGetAuditLog(database *db.DB) http.HandlerFunc { limit := queryInt(r, "limit", 50, 1) offset := queryInt(r, "offset", 0, 0) - entries, err := database.GetAuditLog(limit, offset) + entries, err := database.GetAuditLog(r.Context(), limit, offset) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get audit log") return diff --git a/Server/admin/handlers_settings.go b/Server/admin/handlers_settings.go index 4dabe9be..46e611b1 100644 --- a/Server/admin/handlers_settings.go +++ b/Server/admin/handlers_settings.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "errors" "fmt" @@ -15,7 +16,7 @@ import ( func handleGetSettings(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - settings, err := database.GetAllSettings() + settings, err := database.GetAllSettings(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings") return @@ -48,7 +49,7 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc { return } - if err := validateRequire2FAUpdate(database, normalizedUpdates); err != nil { + if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil { writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) return } @@ -57,13 +58,13 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc { // Apply all settings atomically so a mid-loop failure doesn't leave // partial updates. - tx, err := database.Begin() + tx, err := database.BeginTx(r.Context(), nil) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to start transaction") return } for key, value := range normalizedUpdates { - if _, txErr := tx.Exec( + if _, txErr := tx.ExecContext(r.Context(), `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value, @@ -79,11 +80,11 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc { } for key := range normalizedUpdates { slog.Info("setting changed", "actor_id", actor, "key", key) - db.WriteAudit(database, actor, "setting_change", "setting", 0, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "setting_change", "setting", 0, fmt.Sprintf("%s updated", key)) } - settings, err := database.GetAllSettings() + settings, err := database.GetAllSettings(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings") return @@ -112,8 +113,8 @@ func normalizeSettingUpdates(updates map[string]string) (map[string]string, erro return normalized, nil } -func validateRequire2FAUpdate(database *db.DB, updates map[string]string) error { - targetRequire2FA, err := targetBoolSetting(database, updates, "require_2fa") +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 } @@ -121,7 +122,7 @@ func validateRequire2FAUpdate(database *db.DB, updates map[string]string) error return nil } - registrationOpen, err := targetBoolSetting(database, updates, "registration_open") + registrationOpen, err := targetBoolSetting(ctx, database, updates, "registration_open") if err != nil { return err } @@ -129,7 +130,7 @@ func validateRequire2FAUpdate(database *db.DB, updates map[string]string) error return fmt.Errorf("require_2fa cannot be enabled while registration is open") } - count, err := database.CountUsersWithoutTOTP() + count, err := database.CountUsersWithoutTOTP(ctx) if err != nil { return fmt.Errorf("failed to validate 2FA enrollment") } @@ -139,11 +140,11 @@ func validateRequire2FAUpdate(database *db.DB, updates map[string]string) error return nil } -func targetBoolSetting(database *db.DB, updates map[string]string, key string) (bool, error) { +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(key) + value, err := database.GetSetting(ctx, key) if errors.Is(err, db.ErrNotFound) { return false, nil } diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index 2f2eb0da..af701fdd 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "errors" "fmt" @@ -15,7 +16,7 @@ import ( func handleGetStats(database *db.DB, hub HubBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - stats, err := database.GetServerStats() + stats, err := database.GetServerStats(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats") return @@ -32,7 +33,7 @@ func handleListUsers(database *db.DB) http.HandlerFunc { limit := queryInt(r, "limit", 50, 1) offset := queryInt(r, "offset", 0, 0) - users, err := database.ListAllUsers(limit, offset) + users, err := database.ListAllUsers(r.Context(), limit, offset) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list users") return @@ -81,7 +82,7 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis return } - user, err := database.GetUserByID(id) + user, err := database.GetUserByID(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch user") return @@ -131,7 +132,7 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis } if req.RoleID != nil { - if _, err := database.Exec(`UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { + if _, err := database.ExecContext(r.Context(), `UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role") return } @@ -139,21 +140,21 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis if permInvalidator != nil { permInvalidator.InvalidateUser(id) } - db.WriteAudit(database, actor, "role_change", "user", id, + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "role_change", "user", id, fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID)) - if role, err := database.GetRoleByID(*req.RoleID); err == nil && role != nil { + if role, err := database.GetRoleByID(r.Context(), *req.RoleID); err == nil && role != nil { if hub != nil { hub.BroadcastMemberUpdate(id, role.Name) } } } - updated, err := database.GetUserByID(id) + updated, err := database.GetUserByID(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user") return } - writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(database, updated)) + writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(r.Context(), database, updated)) } } @@ -165,13 +166,13 @@ func handleForceLogout(database *db.DB) http.HandlerFunc { return } - if err := database.ForceLogoutUser(id); err != nil { + if err := database.ForceLogoutUser(r.Context(), id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user") return } actor := actorFromContext(r) slog.Info("force logout", "actor_id", actor, "target_user_id", id) - db.WriteAudit(database, actor, "force_logout", "user", id, "all sessions terminated") + db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "force_logout", "user", id, "all sessions terminated") w.WriteHeader(http.StatusNoContent) } } diff --git a/Server/admin/logstream.go b/Server/admin/logstream.go index e92f5488..097fccbe 100644 --- a/Server/admin/logstream.go +++ b/Server/admin/logstream.go @@ -341,7 +341,10 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { http.Error(w, string(errResp), http.StatusUnauthorized) return } - sess, err := database.GetSessionByTokenHash(entry.tokenHash) + // Stream lifetime == request lifetime, so all session re-checks below + // use the stream request's context. + ctx := r.Context() + sess, err := database.GetSessionByTokenHash(ctx, entry.tokenHash) if err != nil || sess == nil || auth.IsSessionExpired(sess.ExpiresAt) { errResp, _ := json.Marshal(map[string]string{ "error": "UNAUTHORIZED", @@ -351,15 +354,15 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { return } sessionStillAuthorized := func() bool { - current, currentErr := database.GetSessionByTokenHash(entry.tokenHash) + current, currentErr := database.GetSessionByTokenHash(ctx, entry.tokenHash) if currentErr != nil || current == nil || auth.IsSessionExpired(current.ExpiresAt) { return false } - user, userErr := database.GetUserByID(current.UserID) + user, userErr := database.GetUserByID(ctx, current.UserID) if userErr != nil || user == nil { return false } - role, roleErr := database.GetRoleByID(user.RoleID) + role, roleErr := database.GetRoleByID(ctx, user.RoleID) if roleErr != nil || role == nil { return false } @@ -408,7 +411,6 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { keepalive := time.NewTicker(15 * time.Second) defer keepalive.Stop() - ctx := r.Context() for { select { case entry := <-ch: diff --git a/Server/admin/logstream_test.go b/Server/admin/logstream_test.go index 0a7b324d..f2bbb390 100644 --- a/Server/admin/logstream_test.go +++ b/Server/admin/logstream_test.go @@ -73,7 +73,7 @@ func TestHandleLogStream_BackfillStopsAfterSessionRevocation(t *testing.T) { logBuf.Write(LogEntry{Timestamp: "2026-03-29T10:00:00Z", Level: "info", Message: "first", Source: "test"}) logBuf.Write(LogEntry{Timestamp: "2026-03-29T10:00:01Z", Level: "info", Message: "second", Source: "test"}) - userID, err := database.CreateUser("owner", "hash", 1) + userID, err := database.CreateUser(context.Background(), "owner", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -83,7 +83,7 @@ func TestHandleLogStream_BackfillStopsAfterSessionRevocation(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -99,7 +99,7 @@ func TestHandleLogStream_BackfillStopsAfterSessionRevocation(t *testing.T) { writer := &revokingSSEWriter{ header: make(http.Header), revoke: func() { - _ = database.DeleteSession(tokenHash) + _ = database.DeleteSession(context.Background(), tokenHash) }, cancel: cancel, } diff --git a/Server/admin/middleware.go b/Server/admin/middleware.go index 1f8c4b9b..11a9b105 100644 --- a/Server/admin/middleware.go +++ b/Server/admin/middleware.go @@ -31,7 +31,7 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } hash := auth.HashToken(token) - sess, err := database.GetSessionByTokenHash(hash) + sess, err := database.GetSessionByTokenHash(r.Context(), hash) if err != nil || sess == nil { writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") return @@ -42,13 +42,13 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { return } - user, err := database.GetUserByID(sess.UserID) + user, err := database.GetUserByID(r.Context(), sess.UserID) if err != nil || user == nil { writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found") return } - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(r.Context(), user.RoleID) if err != nil || role == nil { writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found") return @@ -77,7 +77,7 @@ func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler { return } - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(r.Context(), user.RoleID) if err != nil || role == nil { writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found") return diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index 40d1c733..7ee7869a 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -157,23 +157,23 @@ func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) { // Create a user initially with a valid role, then mutate role_id to a // nonexistent value (disabling FK checks temporarily so SQLite allows it). - uid, err := database.CreateUser("orphanuser", "$2a$12$x", 1) + uid, err := database.CreateUser(context.Background(), "orphanuser", "$2a$12$x", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(context.Background(), uid) if err != nil || user == nil { t.Fatalf("GetUserByID: %v", err) } // Disable FK enforcement, update role_id, re-enable. - if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil { t.Fatalf("disable FK: %v", err) } - if _, err := database.Exec(`UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil { t.Fatalf("UPDATE role_id: %v", err) } - if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil { t.Fatalf("re-enable FK: %v", err) } user.RoleID = 9999 // mirror the DB value in our in-memory struct @@ -213,11 +213,11 @@ func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) { func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) { database := openWhiteboxTestDB(t) - uid, err := database.CreateUser("ownerpass", "$2a$12$x", 1) + uid, err := database.CreateUser(context.Background(), "ownerpass", "$2a$12$x", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(context.Background(), uid) if err != nil || user == nil { t.Fatalf("GetUserByID: %v", err) } @@ -251,23 +251,23 @@ func TestAdminAuthMiddleware_RoleNotFound(t *testing.T) { database := openWhiteboxTestDB(t) handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil) - uid, err := database.CreateUser("noroleuser", "$2a$12$x", 1) + uid, err := database.CreateUser(context.Background(), "noroleuser", "$2a$12$x", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } token := "norole-token" - if _, err := database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Disable FK enforcement, assign a non-existent role_id, re-enable. - if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil { t.Fatalf("disable FK: %v", err) } - if _, err := database.Exec(`UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil { t.Fatalf("UPDATE role_id: %v", err) } - if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil { t.Fatalf("re-enable FK: %v", err) } diff --git a/Server/admin/middleware_coverage_test.go b/Server/admin/middleware_coverage_test.go index 2c3a6f69..c76cbe8d 100644 --- a/Server/admin/middleware_coverage_test.go +++ b/Server/admin/middleware_coverage_test.go @@ -4,6 +4,7 @@ package admin_test // ownerOnlyMiddleware, and related helpers. import ( + "context" "net/http" "testing" "time" @@ -22,19 +23,19 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { // Create a user and session, then manually expire the session by setting // expires_at to a past timestamp via the exported Exec helper. - uid, err := database.CreateUser("expireduser", "$2a$12$x", 1) + uid, err := database.CreateUser(context.Background(), "expireduser", "$2a$12$x", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } token := "expired-session-token" tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Set expires_at to yesterday so the session is treated as expired. pastTime := time.Now().Add(-24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z") - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `UPDATE sessions SET expires_at = ? WHERE token = ?`, pastTime, tokenHash, ); err != nil { diff --git a/Server/admin/setup_handler.go b/Server/admin/setup_handler.go index 0edbfc44..db342cca 100644 --- a/Server/admin/setup_handler.go +++ b/Server/admin/setup_handler.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/json" "errors" "log/slog" @@ -42,7 +43,7 @@ type setupResponse struct { // handleSetupStatus returns whether initial setup is needed (no users exist). func handleSetupStatus(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - count, err := database.UserCount() + count, err := database.UserCount(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check user count") return @@ -110,7 +111,7 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st // Atomically check no users exist and create the owner (BUG-119). // This closes the TOCTOU race between UserCount() and CreateUser(). - uid, err := database.CreateOwnerIfEmpty(req.Username, hash, ownerRoleID) + uid, err := database.CreateOwnerIfEmpty(r.Context(), req.Username, hash, ownerRoleID) if errors.Is(err, db.ErrConflict) { writeErr(w, http.StatusForbidden, "FORBIDDEN", "setup has already been completed") return @@ -132,27 +133,27 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st if len(device) > maxDeviceLen { device = device[:maxDeviceLen] } - if _, err := database.CreateSession(uid, auth.HashToken(token), device, host); err != nil { + if _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, host); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session") return } // Create default channels under canonical categories. - _, _ = database.CreateChannel("general", "text", "Text Channels", "Welcome to the server!", 0) - _, _ = database.CreateChannel("General", "voice", "Voice Channels", "", 0) + _, _ = database.CreateChannel(r.Context(), "general", "text", "Text Channels", "Welcome to the server!", 0) + _, _ = database.CreateChannel(r.Context(), "General", "voice", "Voice Channels", "", 0) // Generate a bootstrap invite code so the owner can invite others. // Bound it (5 uses / 24h) rather than minting an unlimited, non-expiring // invite — the owner can create fresh invites once logged in. bootstrapInviteExpiry := time.Now().Add(24 * time.Hour) - inviteCode, err := database.CreateInvite(uid, 5, &bootstrapInviteExpiry) + inviteCode, err := database.CreateInvite(r.Context(), uid, 5, &bootstrapInviteExpiry) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate invite code") return } slog.Info("server setup completed", "owner", req.Username, "user_id", uid) - db.WriteAudit(database, uid, "server_setup", "server", 0, + db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "server_setup", "server", 0, "initial setup: owner account created, default channel and invite generated") writeJSON(w, http.StatusCreated, setupResponse{ diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index 8bbb57b2..54331625 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -86,7 +87,7 @@ func TestSetup_CreatesOwner(t *testing.T) { } // Verify user was created with Owner role. - user, err := database.GetUserByUsername("myadmin") + user, err := database.GetUserByUsername(context.Background(), "myadmin") if err != nil || user == nil { t.Fatal("user not found in database after setup") } @@ -185,7 +186,7 @@ func TestSetup_ConcurrentRace(t *testing.T) { } // Verify only one user exists in the database. - count, err := database.UserCount() + count, err := database.UserCount(context.Background()) if err != nil { t.Fatalf("UserCount: %v", err) } diff --git a/Server/admin/types.go b/Server/admin/types.go index 299b0a17..3ba462f5 100644 --- a/Server/admin/types.go +++ b/Server/admin/types.go @@ -1,6 +1,10 @@ package admin -import "github.com/owncord/server/db" +import ( + "context" + + "github.com/owncord/server/db" +) // ─── Context keys ───────────────────────────────────────────────────────────── @@ -93,9 +97,9 @@ func toAdminUserResponse(u db.UserWithRole) adminUserResponse { // toAdminUserResponseFromUser converts a plain db.User to the safe response // shape, resolving the role name via the database. -func toAdminUserResponseFromUser(database *db.DB, u *db.User) adminUserResponse { +func toAdminUserResponseFromUser(ctx context.Context, database *db.DB, u *db.User) adminUserResponse { roleName := "" - if role, err := database.GetRoleByID(u.RoleID); err == nil && role != nil { + if role, err := database.GetRoleByID(ctx, u.RoleID); err == nil && role != nil { roleName = role.Name } return adminUserResponse{ diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index d9d3b8b8..fa8b5710 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -136,9 +137,9 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) { handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) // Create admin user (not owner - role 2) - adminUID, _ := database.CreateUser("adminonly2", "hash", 2) + adminUID, _ := database.CreateUser(context.Background(), "adminonly2", "hash", 2) token := "admin-role-token" - _, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), adminUID, auth.HashToken(token), "test", "127.0.0.1") w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) if w.Code != http.StatusForbidden { diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index b6e83fed..d69ca10d 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "fmt" @@ -107,7 +108,7 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t // handleRegister processes POST /api/v1/auth/register. func handleRegister(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - registrationOpen, err := isRegistrationOpen(database) + registrationOpen, err := isRegistrationOpen(r.Context(), database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -123,7 +124,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { return } - require2FA, err := isRequire2FAEnabled(database) + require2FA, err := isRequire2FAEnabled(r.Context(), database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -190,7 +191,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { // Atomically consume the invite and create the user so failed // registrations do not burn a valid invite code. - uid, err := database.CreateUserWithInvite(req.Username, hash, int(permissions.MemberRoleID), req.InviteCode) + uid, err := database.CreateUserWithInvite(r.Context(), req.Username, hash, int(permissions.MemberRoleID), req.InviteCode) if err != nil { // UNIQUE constraint violation → duplicate username → 400. // Any other DB error → 500. @@ -211,7 +212,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { ip := clientIP(r) slog.Info("user registered", "username", req.Username, "user_id", uid, "ip", ip) - db.WriteAudit(database, uid, "user_register", "user", uid, + db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "user_register", "user", uid, "new account created via invite") // Issue session. @@ -225,7 +226,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { } device := truncateDevice(r.Header.Get("User-Agent")) - if _, err := database.CreateSession(uid, auth.HashToken(token), device, ip); err != nil { + if _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, ip); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to create session", @@ -233,7 +234,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { return } - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(r.Context(), uid) if err != nil || user == nil { slog.Error("failed to fetch user after registration", "user_id", uid, "error", err) writeJSON(w, http.StatusInternalServerError, errorResponse{ @@ -302,7 +303,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. // Constant-time lookup: always attempt bcrypt compare even when user // does not exist to prevent timing-based username enumeration. - user, err := database.GetUserByUsername(req.Username) + user, err := database.GetUserByUsername(r.Context(), req.Username) // Distinguish DB errors from authentication failures. DB errors // should NOT increment the rate limiter — otherwise a transient @@ -334,11 +335,11 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. if !auth.CheckPassword(storedHash, req.Password) { // Track failures per-IP; lockout on threshold. if !limiter.Allow(failKey, loginFailureThreshold, loginFailureWindow) { - limiter.Lockout(lockKey, loginLockoutDuration) + limiter.Lockout(r.Context(), lockKey, loginLockoutDuration) } // BUG-110: Track failures per-username; lockout on threshold. if !limiter.Allow(userFailKey, loginUserFailureThreshold, loginUserFailureWindow) { - limiter.Lockout(userLockKey, loginUserLockoutDuration) + limiter.Lockout(r.Context(), userLockKey, loginUserLockoutDuration) } slog.Info("login failed", "ip", ip, "username_len", len(req.Username)) writeJSON(w, http.StatusUnauthorized, errorResponse{ @@ -349,12 +350,12 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } // Reset failure counters on success. - limiter.Reset(failKey) - limiter.Reset(userFailKey) + limiter.Reset(r.Context(), failKey) + limiter.Reset(r.Context(), userFailKey) if auth.IsEffectivelyBanned(user) { slog.Warn("banned user login attempt", "username", user.Username, "user_id", user.ID, "ip", ip) - db.WriteAudit(database, user.ID, "login_blocked_banned", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "login_blocked_banned", "user", user.ID, "banned user attempted login from "+ip) writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", @@ -363,7 +364,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. return } - require2FA, err := isRequire2FAEnabled(database) + require2FA, err := isRequire2FAEnabled(r.Context(), database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -395,7 +396,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } // Issue session. - token, err := issueSession(database, user.ID, truncateDevice(r.Header.Get("User-Agent")), ip) + token, err := issueSession(r.Context(), database, user.ID, truncateDevice(r.Header.Get("User-Agent")), ip) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -409,7 +410,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. // would leave the user permanently "online" if they never open a WS // connection or if the client crashes before connecting. slog.Info("user logged in", "username", user.Username, "user_id", user.ID, "ip", ip) - db.WriteAudit(database, user.ID, "user_login", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "user_login", "user", user.ID, "logged in from "+ip) writeJSON(w, http.StatusOK, authSuccessResponse{ Token: token, @@ -431,7 +432,9 @@ func handleLogout(database *db.DB) http.HandlerFunc { return } - if err := database.DeleteSession(sess.TokenHash); err != nil { + // The client clears its token optimistically — once logout reaches the + // server, the revocation must not die with a dropped connection. + if err := database.DeleteSession(context.WithoutCancel(r.Context()), sess.TokenHash); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to logout", @@ -440,7 +443,7 @@ func handleLogout(database *db.DB) http.HandlerFunc { } slog.Info("user logged out", "user_id", sess.UserID) - db.WriteAudit(database, sess.UserID, "user_logout", "user", sess.UserID, "") + db.WriteAudit(context.WithoutCancel(r.Context()), database, sess.UserID, "user_logout", "user", sess.UserID, "") w.WriteHeader(http.StatusNoContent) } @@ -511,7 +514,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle failKey := fmt.Sprintf("delete_fail:%d", user.ID) if !auth.CheckPassword(user.PasswordHash, req.Password) { if !limiter.Allow(failKey, deleteAccountFailureThreshold, deleteAccountFailureWindow) { - limiter.Lockout(lockKey, deleteAccountLockoutDuration) + limiter.Lockout(r.Context(), lockKey, deleteAccountLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", @@ -519,7 +522,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) if err := database.DeleteAccount(r.Context(), user.ID); err != nil { if errors.Is(err, db.ErrLastAdmin) { @@ -539,7 +542,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle ip := clientIP(r) slog.Info("account deleted", "username", user.Username, "user_id", user.ID, "ip", ip) - db.WriteAudit(database, user.ID, "account_deleted", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "account_deleted", "user", user.ID, "account self-deleted from "+ip) w.WriteHeader(http.StatusNoContent) @@ -574,27 +577,27 @@ func truncateDevice(ua string) string { return ua } -func issueSession(database *db.DB, userID int64, device, ip string) (string, error) { +func issueSession(ctx context.Context, database *db.DB, userID int64, device, ip string) (string, error) { token, err := auth.GenerateToken() if err != nil { return "", err } - if _, err := database.CreateSession(userID, auth.HashToken(token), device, ip); err != nil { + if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), device, ip); err != nil { return "", err } return token, nil } -func isRequire2FAEnabled(database *db.DB) (bool, error) { - return getBooleanSetting(database, "require_2fa", false) +func isRequire2FAEnabled(ctx context.Context, database *db.DB) (bool, error) { + return getBooleanSetting(ctx, database, "require_2fa", false) } -func isRegistrationOpen(database *db.DB) (bool, error) { - return getBooleanSetting(database, "registration_open", true) +func isRegistrationOpen(ctx context.Context, database *db.DB) (bool, error) { + return getBooleanSetting(ctx, database, "registration_open", true) } -func getBooleanSetting(database *db.DB, key string, defaultValue bool) (bool, error) { - value, err := database.GetSetting(key) +func getBooleanSetting(ctx context.Context, database *db.DB, key string, defaultValue bool) (bool, error) { + value, err := database.GetSetting(ctx, key) if err != nil { if errors.Is(err, db.ErrNotFound) { return defaultValue, nil diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go index 6f1eeb67..9720e74f 100644 --- a/Server/api/auth_handler_test.go +++ b/Server/api/auth_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -104,8 +105,8 @@ func TestRegister_Success(t *testing.T) { router := buildAuthRouter(database, limiter) // Create an invite first. - ownerID, _ := database.CreateUser("owner", "hash", 1) - code, _ := database.CreateInvite(ownerID, 1, nil) + ownerID, _ := database.CreateUser(context.Background(), "owner", "hash", 1) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "newuser", @@ -132,12 +133,12 @@ func TestRegister_RegistrationClosed(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - if _, err := database.Exec(`UPDATE settings SET value = '0' WHERE key = 'registration_open'`); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = '0' WHERE key = 'registration_open'`); err != nil { t.Fatalf("close registration: %v", err) } - ownerID, _ := database.CreateUser("owner", "hash", 1) - code, _ := database.CreateInvite(ownerID, 1, nil) + ownerID, _ := database.CreateUser(context.Background(), "owner", "hash", 1) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "closeduser", @@ -171,8 +172,8 @@ func TestRegister_WeakPassword(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - ownerID, _ := database.CreateUser("owner2", "hash", 1) - code, _ := database.CreateInvite(ownerID, 1, nil) + ownerID, _ := database.CreateUser(context.Background(), "owner2", "hash", 1) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "newuser", @@ -190,8 +191,8 @@ func TestRegister_InviteUsedUp(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - ownerID, _ := database.CreateUser("owner3", "hash", 1) - code, _ := database.CreateInvite(ownerID, 1, nil) // max 1 use + ownerID, _ := database.CreateUser(context.Background(), "owner3", "hash", 1) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) // max 1 use // First registration should succeed. postJSON(t, router, "/api/v1/auth/register", map[string]string{ @@ -217,9 +218,9 @@ func TestRegister_DuplicateUsername_DoesNotConsumeInvite(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - ownerID, _ := database.CreateUser("owner4", "hash", 1) - _, _ = database.CreateUser("takenuser", "hash", 4) - code, _ := database.CreateInvite(ownerID, 1, nil) + ownerID, _ := database.CreateUser(context.Background(), "owner4", "hash", 1) + _, _ = database.CreateUser(context.Background(), "takenuser", "hash", 4) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) duplicate := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "takenuser", @@ -277,7 +278,7 @@ func TestLogin_Success(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("loginuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "loginuser", hash, 4) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "loginuser", @@ -301,7 +302,7 @@ func TestLogin_WrongPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("loginuser2", hash, 4) + _, _ = database.CreateUser(context.Background(), "loginuser2", hash, 4) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "loginuser2", @@ -361,7 +362,7 @@ func TestLogin_UsernameLockoutAcrossDifferentIPs(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("lockoutuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "lockoutuser", hash, 4) for i := 0; i < 10; i++ { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ @@ -388,7 +389,7 @@ func TestLogin_UsernameLockoutBlocksCorrectPasswordFromFreshIP(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("lockoutcorrect", hash, 4) + _, _ = database.CreateUser(context.Background(), "lockoutcorrect", hash, 4) for i := 0; i < 10; i++ { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ @@ -419,7 +420,7 @@ func TestLogin_UsernameLockoutIgnoresCasing(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("casehunt", hash, 4) + _, _ = database.CreateUser(context.Background(), "casehunt", hash, 4) // Trip the per-username lockout using the lowercase spelling, from many IPs // so the per-IP limiter is never the binding cap. @@ -449,7 +450,7 @@ func TestLogin_SuccessResetsUsernameFailureCounter(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("resetuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "resetuser", hash, 4) for i := 0; i < 8; i++ { rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ @@ -503,8 +504,8 @@ func TestLogin_RequiresTOTPChallenge(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totpuser", hash, 4) - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { + userID, _ := database.CreateUser(context.Background(), "totpuser", hash, 4) + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -538,8 +539,8 @@ func TestLogin_UsernameLockoutBlocksTOTPChallenge(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totplocked", hash, 4) - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { + userID, _ := database.CreateUser(context.Background(), "totplocked", hash, 4) + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -576,9 +577,9 @@ func TestVerifyTotp_Success(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totpverify", hash, 4) + userID, _ := database.CreateUser(context.Background(), "totpverify", hash, 4) secret := "JBSWY3DPEHPK3PXP" - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -626,9 +627,9 @@ func TestEnableConfirmDisableTotp(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("enrolltotp", hash, 4) + userID, _ := database.CreateUser(context.Background(), "enrolltotp", hash, 4) token, _ := auth.GenerateToken() - if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -646,7 +647,7 @@ func TestEnableConfirmDisableTotp(t *testing.T) { t.Fatal("expected qr_uri from enable response") } - userBeforeConfirm, err := database.GetUserByID(userID) + userBeforeConfirm, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID before confirm: %v", err) } @@ -672,7 +673,7 @@ func TestEnableConfirmDisableTotp(t *testing.T) { t.Fatalf("confirm status = %d, want 204; body = %s", confirm.Code, confirm.Body.String()) } - userAfterConfirm, err := database.GetUserByID(userID) + userAfterConfirm, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID after confirm: %v", err) } @@ -694,7 +695,7 @@ func TestEnableConfirmDisableTotp(t *testing.T) { t.Fatalf("disable status = %d, want 204; body = %s", deleteRec.Code, deleteRec.Body.String()) } - userAfterDelete, err := database.GetUserByID(userID) + userAfterDelete, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID after delete: %v", err) } @@ -709,9 +710,9 @@ func TestTOTPManagement_RequiresPasswordConfirmation(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totppassword", hash, 4) + userID, _ := database.CreateUser(context.Background(), "totppassword", hash, 4) token, _ := auth.GenerateToken() - if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -720,7 +721,7 @@ func TestTOTPManagement_RequiresPasswordConfirmation(t *testing.T) { t.Fatalf("enable status = %d, want 400; body = %s", enable.Code, enable.Body.String()) } - userAfterEnable, err := database.GetUserByID(userID) + userAfterEnable, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID after failed enable: %v", err) } @@ -749,9 +750,9 @@ func TestVerifyTotp_ConsumesChallengeAfterRepeatedFailures(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - userID, _ := database.CreateUser("totplockout", hash, 4) + userID, _ := database.CreateUser(context.Background(), "totplockout", hash, 4) secret := "JBSWY3DPEHPK3PXP" - if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = ?`, secret, userID); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -794,15 +795,15 @@ func TestLogin_Require2FASettingRejectsUsersWithoutEnrollment(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - if _, err := database.Exec(`UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil { t.Fatalf("enable require_2fa: %v", err) } - if _, err := database.Exec(`UPDATE settings SET value = 'false' WHERE key = 'registration_open'`); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'false' WHERE key = 'registration_open'`); err != nil { t.Fatalf("disable registration_open: %v", err) } hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("needsenrollment", hash, 4) + _, _ = database.CreateUser(context.Background(), "needsenrollment", hash, 4) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "needsenrollment", @@ -820,8 +821,8 @@ func TestLogin_BannedUser(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - id, _ := database.CreateUser("banned", hash, 4) - _ = database.BanUser(id, "violated rules", nil) + id, _ := database.CreateUser(context.Background(), "banned", hash, 4) + _ = database.BanUser(context.Background(), id, "violated rules", nil) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "banned", @@ -852,10 +853,10 @@ func TestLogout_Success(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("logoutuser", hash, 4) + uid, _ := database.CreateUser(context.Background(), "logoutuser", hash, 4) token, _ := auth.GenerateToken() tokenHash := auth.HashToken(token) - _, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1") rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil) @@ -864,7 +865,7 @@ func TestLogout_Success(t *testing.T) { } // Session should be gone. - sess, _ := database.GetSessionByTokenHash(tokenHash) + sess, _ := database.GetSessionByTokenHash(context.Background(), tokenHash) if sess != nil { t.Error("Session still exists after logout") } @@ -893,9 +894,9 @@ func TestMe_Success(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("meuser", hash, 4) + uid, _ := database.CreateUser(context.Background(), "meuser", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := getWithToken(t, router, "/api/v1/auth/me", token) @@ -940,7 +941,7 @@ func TestLogin_PasswordWithLeadingSpaceIsPreserved(t *testing.T) { // Hash the password WITH the leading space — this is what was registered. hash, _ := auth.HashPassword(" securePass1") - _, _ = database.CreateUser("spacepassuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "spacepassuser", hash, 4) // Login with the exact same password (including space) must succeed. rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -962,7 +963,7 @@ func TestLogin_PasswordWithLeadingSpaceTrimmedFails(t *testing.T) { // Register with password that has a leading space. hash, _ := auth.HashPassword(" securePass1") - _, _ = database.CreateUser("spacepassuser2", hash, 4) + _, _ = database.CreateUser(context.Background(), "spacepassuser2", hash, 4) // Login without the leading space must fail. rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -983,7 +984,7 @@ func TestLogin_PasswordWithTrailingSpaceIsPreserved(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("securePass1 ") - _, _ = database.CreateUser("trailingspaceuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "trailingspaceuser", hash, 4) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "trailingspaceuser", @@ -1003,7 +1004,7 @@ func TestLogin_UsernameIsStillTrimmed(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - _, _ = database.CreateUser("trimuser", hash, 4) + _, _ = database.CreateUser(context.Background(), "trimuser", hash, 4) // Username with surrounding spaces should resolve to "trimuser". rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -1023,12 +1024,12 @@ func TestRegister_RateLimit(t *testing.T) { limiter := auth.NewRateLimiter() router := buildAuthRouter(database, limiter) - ownerID, _ := database.CreateUser("rl_owner", "hash", 1) + ownerID, _ := database.CreateUser(context.Background(), "rl_owner", "hash", 1) // Attempt register 4 times (limit=3) — 4th should be rate-limited. var lastCode int for i := range 4 { - code, _ := database.CreateInvite(ownerID, 1, nil) + code, _ := database.CreateInvite(context.Background(), ownerID, 1, nil) rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ "username": "rl_user" + string(rune('0'+i)), "password": "securePass1", @@ -1064,10 +1065,10 @@ func TestDeleteAccount_Success(t *testing.T) { hash, _ := auth.HashPassword("correctPass1") // Create as Member (role_id=4) so the last-admin check does not block deletion. - uid, _ := database.CreateUser("deleteuser", hash, 4) + uid, _ := database.CreateUser(context.Background(), "deleteuser", hash, 4) token, _ := auth.GenerateToken() tokenHash := auth.HashToken(token) - _, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1") rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{ "password": "correctPass1", @@ -1078,7 +1079,7 @@ func TestDeleteAccount_Success(t *testing.T) { } // User should be anonymised (banned, username changed). - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(context.Background(), uid) if err != nil { t.Fatalf("GetUserByID after delete: %v", err) } @@ -1093,7 +1094,7 @@ func TestDeleteAccount_Success(t *testing.T) { } // Session should be gone. - sess, _ := database.GetSessionByTokenHash(tokenHash) + sess, _ := database.GetSessionByTokenHash(context.Background(), tokenHash) if sess != nil { t.Error("session should be deleted after account deletion") } @@ -1105,9 +1106,9 @@ func TestDeleteAccount_MissingPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("delnopass", hash, 4) + uid, _ := database.CreateUser(context.Background(), "delnopass", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{}) @@ -1122,9 +1123,9 @@ func TestDeleteAccount_WrongPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("delwrong", hash, 4) + uid, _ := database.CreateUser(context.Background(), "delwrong", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{ "password": "wrongPassword1", @@ -1135,7 +1136,7 @@ func TestDeleteAccount_WrongPassword(t *testing.T) { } // Verify user is NOT deleted. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user == nil || user.Banned { t.Error("user should not be deleted after wrong password") } @@ -1148,9 +1149,9 @@ func TestDeleteAccount_LastAdmin(t *testing.T) { hash, _ := auth.HashPassword("correctPass1") // Create as Owner (role_id=1) — the only admin-class user. - uid, _ := database.CreateUser("lastadmin", hash, 1) + uid, _ := database.CreateUser(context.Background(), "lastadmin", hash, 1) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token, map[string]string{ "password": "correctPass1", @@ -1161,7 +1162,7 @@ func TestDeleteAccount_LastAdmin(t *testing.T) { } // User should still be intact. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user == nil || user.Banned { t.Error("last admin should not be deleted") } @@ -1189,9 +1190,9 @@ func TestDeleteAccount_LockoutAfterRepeatedFailures(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("dellockout", hash, 4) + uid, _ := database.CreateUser(context.Background(), "dellockout", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // 3 failures should trigger lockout on the 4th attempt. for i := 0; i < 4; i++ { @@ -1218,9 +1219,9 @@ func TestConfirmTOTP_InvalidCode(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("totpbadcode", hash, 4) + uid, _ := database.CreateUser(context.Background(), "totpbadcode", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Enable TOTP first to get a pending secret. enable := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"}) @@ -1239,7 +1240,7 @@ func TestConfirmTOTP_InvalidCode(t *testing.T) { } // Secret should NOT be persisted. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user.TOTPSecret != nil { t.Error("TOTP secret should not be persisted after invalid code") } @@ -1251,9 +1252,9 @@ func TestConfirmTOTP_NoPendingSecret(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("totpnopending", hash, 4) + uid, _ := database.CreateUser(context.Background(), "totpnopending", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Confirm without enabling first — no pending secret. confirm := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token, map[string]string{ @@ -1272,9 +1273,9 @@ func TestConfirmTOTP_MissingPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("totpnoconfirmpass", hash, 4) + uid, _ := database.CreateUser(context.Background(), "totpnoconfirmpass", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Enable TOTP first. postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"}) @@ -1295,9 +1296,9 @@ func TestConfirmTOTP_WrongPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("totpwrongconfirm", hash, 4) + uid, _ := database.CreateUser(context.Background(), "totpwrongconfirm", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Enable TOTP first. postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token, map[string]string{"password": "correctPass1"}) @@ -1337,12 +1338,12 @@ func TestDisableTOTP_WrongPassword(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("disabletotpwrong", hash, 4) + uid, _ := database.CreateUser(context.Background(), "disabletotpwrong", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Set TOTP secret directly. - if _, err := database.Exec(`UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -1359,7 +1360,7 @@ func TestDisableTOTP_WrongPassword(t *testing.T) { } // TOTP should still be enabled. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user.TOTPSecret == nil { t.Error("TOTP secret should still be set after wrong password") } @@ -1371,17 +1372,17 @@ func TestDisableTOTP_Require2FABlocksDisable(t *testing.T) { router := buildAuthRouter(database, limiter) // Enable require_2fa setting. - if _, err := database.Exec(`UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE settings SET value = 'true' WHERE key = 'require_2fa'`); err != nil { t.Fatalf("enable require_2fa: %v", err) } hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("disabletotpreq", hash, 4) + uid, _ := database.CreateUser(context.Background(), "disabletotpreq", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") // Set TOTP secret directly. - if _, err := database.Exec(`UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP' WHERE id = ?`, uid); err != nil { t.Fatalf("set totp secret: %v", err) } @@ -1398,7 +1399,7 @@ func TestDisableTOTP_Require2FABlocksDisable(t *testing.T) { } // TOTP should still be enabled. - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user.TOTPSecret == nil { t.Error("TOTP secret should still be set when require_2fa is enabled") } @@ -1440,10 +1441,10 @@ func TestLogout_SessionGoneAfterLogout(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("logoutsess", hash, 4) + uid, _ := database.CreateUser(context.Background(), "logoutsess", hash, 4) token, _ := auth.GenerateToken() tokenHash := auth.HashToken(token) - _, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, tokenHash, "test", "127.0.0.1") // First logout should succeed. rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil) @@ -1466,9 +1467,9 @@ func TestMe_ReturnsCorrectUserFields(t *testing.T) { router := buildAuthRouter(database, limiter) hash, _ := auth.HashPassword("correctPass1") - uid, _ := database.CreateUser("medetailed", hash, 4) + uid, _ := database.CreateUser(context.Background(), "medetailed", hash, 4) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") rr := getWithToken(t, router, "/api/v1/auth/me", token) @@ -1526,9 +1527,9 @@ func containsStr(s, sub string) bool { func expiredInviteDB(t *testing.T) (*db.DB, string) { t.Helper() database := newAuthTestDB(t) - ownerID, _ := database.CreateUser("expowner", "hash", 1) + ownerID, _ := database.CreateUser(context.Background(), "expowner", "hash", 1) past := time.Now().Add(-time.Hour) - code, _ := database.CreateInvite(ownerID, 0, &past) + code, _ := database.CreateInvite(context.Background(), ownerID, 0, &past) return database, code } diff --git a/Server/api/channel_authz_test.go b/Server/api/channel_authz_test.go index e339abb0..a6b1a8f9 100644 --- a/Server/api/channel_authz_test.go +++ b/Server/api/channel_authz_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -18,7 +19,7 @@ import ( // given role on the given channel. func denyReadMessages(t *testing.T, database *db.DB, channelID, roleID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, channelID, roleID, permissions.ReadMessages, ) @@ -36,8 +37,8 @@ func TestChannelList_FiltersOutDeniedChannels(t *testing.T) { // Create member user (roleID=4, has READ_MESSAGES by default). token := chTestCreateToken(t, database, "authz-member1", 4) - chVisible, _ := database.CreateChannel("visible", "text", "", "", 0) - chHidden, _ := database.CreateChannel("hidden", "text", "", "", 1) + chVisible, _ := database.CreateChannel(context.Background(), "visible", "text", "", "", 0) + chHidden, _ := database.CreateChannel(context.Background(), "hidden", "text", "", "", 1) _ = chVisible // used implicitly in response // Deny READ_MESSAGES on the hidden channel for the Member role. @@ -70,8 +71,8 @@ func TestChannelList_AdminSeesAllChannels(t *testing.T) { // Owner (roleID=1) has Administrator bit — bypasses all checks. token := chTestCreateToken(t, database, "authz-owner1", 1) - chA, _ := database.CreateChannel("a", "text", "", "", 0) - chB, _ := database.CreateChannel("b", "text", "", "", 1) + chA, _ := database.CreateChannel(context.Background(), "a", "text", "", "", 0) + chB, _ := database.CreateChannel(context.Background(), "b", "text", "", "", 1) // Deny READ_MESSAGES on both channels for all roles. denyReadMessages(t, database, chA, permissions.MemberRoleID) @@ -96,7 +97,7 @@ func TestChannelMessages_DeniedByPermission(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-member2", 4) - chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny READ_MESSAGES for Member role on this channel. denyReadMessages(t, database, chID, permissions.MemberRoleID) @@ -112,7 +113,7 @@ func TestChannelMessages_AdminBypassesDeny(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-owner2", 1) - chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny READ_MESSAGES for Member role — should not affect Owner. denyReadMessages(t, database, chID, permissions.MemberRoleID) @@ -131,17 +132,17 @@ func TestSearch_FiltersResultsByPermission(t *testing.T) { // Create an owner to insert messages (owner can write anywhere). _ = chTestCreateToken(t, database, "authz-owner3", 1) - owner, _ := database.GetUserByUsername("authz-owner3") + owner, _ := database.GetUserByUsername(context.Background(), "authz-owner3") // Member user for search. memberToken := chTestCreateToken(t, database, "authz-member3", 4) - chVisible, _ := database.CreateChannel("pub", "text", "", "", 0) - chHidden, _ := database.CreateChannel("priv", "text", "", "", 1) + chVisible, _ := database.CreateChannel(context.Background(), "pub", "text", "", "", 0) + chHidden, _ := database.CreateChannel(context.Background(), "priv", "text", "", "", 1) // Insert messages in both channels with a common keyword. - _, _ = database.CreateMessage(chVisible, owner.ID, "searchable keyword public", nil) - _, _ = database.CreateMessage(chHidden, owner.ID, "searchable keyword private", nil) + _, _ = database.CreateMessage(context.Background(), chVisible, owner.ID, "searchable keyword public", nil) + _, _ = database.CreateMessage(context.Background(), chHidden, owner.ID, "searchable keyword private", nil) // Deny READ_MESSAGES on the hidden channel for members. denyReadMessages(t, database, chHidden, permissions.MemberRoleID) @@ -167,13 +168,13 @@ func TestSearch_AdminSeesAllResults(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "authz-owner4", 1) - owner, _ := database.GetUserByUsername("authz-owner4") + owner, _ := database.GetUserByUsername(context.Background(), "authz-owner4") - chA, _ := database.CreateChannel("a", "text", "", "", 0) - chB, _ := database.CreateChannel("b", "text", "", "", 1) + chA, _ := database.CreateChannel(context.Background(), "a", "text", "", "", 0) + chB, _ := database.CreateChannel(context.Background(), "b", "text", "", "", 1) - _, _ = database.CreateMessage(chA, owner.ID, "findme alpha", nil) - _, _ = database.CreateMessage(chB, owner.ID, "findme beta", nil) + _, _ = database.CreateMessage(context.Background(), chA, owner.ID, "findme alpha", nil) + _, _ = database.CreateMessage(context.Background(), chB, owner.ID, "findme beta", nil) // Deny READ_MESSAGES on both for member role — admin bypasses. denyReadMessages(t, database, chA, permissions.MemberRoleID) @@ -200,8 +201,8 @@ func TestChannelList_ExcludesDMChannels_Member(t *testing.T) { token := chTestCreateToken(t, database, "dm-excl-member", 4) // Create a normal text channel and a DM channel. - database.CreateChannel("general", "text", "", "", 0) - database.Exec(`INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) + database.CreateChannel(context.Background(), "general", "text", "", "", 0) + database.ExecContext(context.Background(), `INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { @@ -227,9 +228,9 @@ func TestChannelList_ExcludesDMChannels_Admin(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "dm-excl-admin", 1) // Owner - database.CreateChannel("general", "text", "", "", 0) - database.CreateChannel("voice", "voice", "", "", 1) - database.Exec(`INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) + database.CreateChannel(context.Background(), "general", "text", "", "", 0) + database.CreateChannel(context.Background(), "voice", "voice", "", "", 1) + database.ExecContext(context.Background(), `INSERT INTO channels (name, type, position) VALUES ('dm-1', 'dm', 0)`) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go index 978790e6..7ab96263 100644 --- a/Server/api/channel_handler.go +++ b/Server/api/channel_handler.go @@ -131,7 +131,7 @@ func handleGetMessages(svc *service.Services) http.HandlerFunc { limit = v } - msgs, hasMore, err := svc.Messages.GetMessages(user.ID, channelID, before, limit) + msgs, hasMore, err := svc.Messages.GetMessages(r.Context(), user.ID, channelID, before, limit) if err != nil { writeServiceError(w, err) return @@ -191,7 +191,7 @@ func handleSearch(svc *service.Services) http.HandlerFunc { limit = v } - results, err := svc.Messages.SearchMessages(user.ID, q, channelID, limit) + results, err := svc.Messages.SearchMessages(r.Context(), user.ID, q, channelID, limit) if err != nil { if isInvalidSearchQueryError(err) { writeJSON(w, http.StatusBadRequest, errorResponse{ @@ -229,7 +229,7 @@ func handleGetPins(svc *service.Services) http.HandlerFunc { return } - msgs, err := svc.Messages.GetPinnedMessages(user.ID, channelID) + msgs, err := svc.Messages.GetPinnedMessages(r.Context(), user.ID, channelID) if err != nil { writeServiceError(w, err) return @@ -263,7 +263,7 @@ func handleSetPinned(svc *service.Services, pinned bool) http.HandlerFunc { return } - if err := svc.Messages.SetMessagePinned(user.ID, channelID, messageID, pinned); err != nil { + if err := svc.Messages.SetMessagePinned(r.Context(), user.ID, channelID, messageID, pinned); err != nil { writeServiceError(w, err) return } diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index 0c0b7a97..388b921e 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -197,13 +198,13 @@ func buildChannelRouter(database *db.DB) http.Handler { // chTestCreateToken creates a user+session and returns the plaintext token. func chTestCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() - _, err := database.CreateUser(username, "$2a$12$fake", roleID) + _, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID) if err != nil { t.Fatalf("CreateUser %q: %v", username, err) } token := "chtest-token-" + username hash := auth.HashToken(token) - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`, hash, username, @@ -257,8 +258,8 @@ func TestChannelList_WithChannels(t *testing.T) { router := buildChannelRouter(database) token := chTestCreateToken(t, database, "bob", 1) - _, _ = database.CreateChannel("general", "text", "", "", 0) - _, _ = database.CreateChannel("random", "text", "", "", 1) + _, _ = database.CreateChannel(context.Background(), "general", "text", "", "", 0) + _, _ = database.CreateChannel(context.Background(), "random", "text", "", "", 1) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { @@ -307,7 +308,7 @@ func TestChannelMessages_EmptyChannel(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "eve", 1) - chID, _ := database.CreateChannel("general", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) if rr.Code != http.StatusOK { @@ -325,11 +326,11 @@ func TestChannelMessages_ReturnsMessages(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "frank", 1) - user, _ := database.GetUserByUsername("frank") - chID, _ := database.CreateChannel("ch", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "frank") + chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0) for i := range 3 { - _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("msg%d", i), nil) } rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) @@ -348,7 +349,7 @@ func TestChannelMessages_LimitCappedAt100(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "grace", 1) - chID, _ := database.CreateChannel("ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0) // limit=200 should succeed (capped internally). rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=200", chID), token) @@ -361,11 +362,11 @@ func TestChannelMessages_HasMore(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "henry", 1) - user, _ := database.GetUserByUsername("henry") - chID, _ := database.CreateChannel("ch", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "henry") + chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0) for i := range 60 { - _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil) } rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=50", chID), token) @@ -383,11 +384,11 @@ func TestChannelMessages_HasMoreFalse(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "ivan", 1) - user, _ := database.GetUserByUsername("ivan") - chID, _ := database.CreateChannel("ch", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "ivan") + chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0) for i := range 5 { - _, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("m%d", i), nil) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil) } rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=50", chID), token) @@ -426,9 +427,9 @@ func TestSearch_ReturnsResults(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "kim", 1) - user, _ := database.GetUserByUsername("kim") - chID, _ := database.CreateChannel("searchable", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "uniqueterm in message", nil) + user, _ := database.GetUserByUsername(context.Background(), "kim") + chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "uniqueterm in message", nil) rr := chGet(t, router, "/api/v1/search?q=uniqueterm", token) if rr.Code != http.StatusOK { @@ -463,9 +464,9 @@ func TestSearch_WithChannelID(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "searchch", 1) - user, _ := database.GetUserByUsername("searchch") - chID, _ := database.CreateChannel("filtered", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "filtered message here", nil) + user, _ := database.GetUserByUsername(context.Background(), "searchch") + chID, _ := database.CreateChannel(context.Background(), "filtered", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "filtered message here", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=filtered&channel_id=%d", chID), token) if rr.Code != http.StatusOK { @@ -521,9 +522,9 @@ func TestSearch_InvalidFTSQuery(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "badfts", 1) - user, _ := database.GetUserByUsername("badfts") - chID, _ := database.CreateChannel("fts", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "search seed", nil) + user, _ := database.GetUserByUsername(context.Background(), "badfts") + chID, _ := database.CreateChannel(context.Background(), "fts", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "search seed", nil) // FTS5 operator characters are now stripped by sanitizeFTSQuery, so a // bare quote becomes an empty query which returns 200 with no results. @@ -560,22 +561,22 @@ func TestSearch_ChannelTypeLookupFailure_FailsClosed(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "searchfailclosed", 1) - user, _ := database.GetUserByUsername("searchfailclosed") - chID, _ := database.CreateChannel("searchable", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "closedlookupterm", nil) + user, _ := database.GetUserByUsername(context.Background(), "searchfailclosed") + chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "closedlookupterm", nil) - _, err := database.Exec(`ALTER TABLE channels RENAME TO channels_with_type`) + _, err := database.ExecContext(context.Background(), `ALTER TABLE channels RENAME TO channels_with_type`) if err != nil { t.Fatalf("rename channels: %v", err) } - _, err = database.Exec(`CREATE TABLE channels ( + _, err = database.ExecContext(context.Background(), `CREATE TABLE channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL )`) if err != nil { t.Fatalf("recreate channels without type: %v", err) } - _, err = database.Exec(`INSERT INTO channels (id, name) SELECT id, name FROM channels_with_type`) + _, err = database.ExecContext(context.Background(), `INSERT INTO channels (id, name) SELECT id, name FROM channels_with_type`) if err != nil { t.Fatalf("copy channels: %v", err) } @@ -590,11 +591,11 @@ func TestSearch_ChannelOverrideLookupFailure_ReturnsError(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "searchoverridefail", 4) - user, _ := database.GetUserByUsername("searchoverridefail") - chID, _ := database.CreateChannel("searchable", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "overridefailterm", nil) + user, _ := database.GetUserByUsername(context.Background(), "searchoverridefail") + chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "overridefailterm", nil) - _, err := database.Exec(`DROP TABLE channel_overrides`) + _, err := database.ExecContext(context.Background(), `DROP TABLE channel_overrides`) if err != nil { t.Fatalf("drop channel_overrides: %v", err) } @@ -642,12 +643,12 @@ func TestChannelMessages_BeforeCursor(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "cursoruser", 1) - user, _ := database.GetUserByUsername("cursoruser") - chID, _ := database.CreateChannel("cursor", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "cursoruser") + chID, _ := database.CreateChannel(context.Background(), "cursor", "text", "", "", 0) var lastID int64 for i := range 5 { - lastID, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil) + lastID, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("msg%d", i), nil) } rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, lastID), token) @@ -660,7 +661,7 @@ func TestChannelMessages_InvalidLimit(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "badlimituser", 1) - chID, _ := database.CreateChannel("lim", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "lim", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=abc", chID), token) if rr.Code != http.StatusBadRequest { @@ -675,7 +676,7 @@ func newPinTestDB(t *testing.T) *db.DB { t.Helper() database := newChannelTestDB(t) // Add DM tables required by pin handlers for DM authorization. - _, err := database.Exec(` + _, err := database.ExecContext(context.Background(), ` CREATE TABLE IF NOT EXISTS dm_participants ( channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -718,7 +719,7 @@ func TestGetPins_EmptyPins(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinuser2", 1) - chID, _ := database.CreateChannel("general", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token) if rr.Code != http.StatusOK { @@ -742,13 +743,13 @@ func TestGetPins_ReturnsPinnedMessages(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinuser3", 1) - user, _ := database.GetUserByUsername("pinuser3") - chID, _ := database.CreateChannel("general", "text", "", "", 0) + user, _ := database.GetUserByUsername(context.Background(), "pinuser3") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "pinned message", nil) - _ = database.SetMessagePinned(msgID, true) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "pinned message", nil) + _ = database.SetMessagePinned(context.Background(), msgID, true) // Also create an unpinned message — should not appear. - _, _ = database.CreateMessage(chID, user.ID, "not pinned", nil) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "not pinned", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token) if rr.Code != http.StatusOK { @@ -773,11 +774,11 @@ func TestGetPins_DMChannel_NonParticipantForbidden(t *testing.T) { chTestCreateToken(t, database, "dmuser2", 4) outsiderToken := chTestCreateToken(t, database, "outsider", 4) - user1, _ := database.GetUserByUsername("dmuser1") - user2, _ := database.GetUserByUsername("dmuser2") + user1, _ := database.GetUserByUsername(context.Background(), "dmuser1") + user2, _ := database.GetUserByUsername(context.Background(), "dmuser2") // Create a DM channel manually. - dmCh, _, _ := database.GetOrCreateDMChannel(user1.ID, user2.ID) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), user1.ID, user2.ID) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", dmCh.ID), outsiderToken) if rr.Code != http.StatusNotFound { @@ -791,10 +792,10 @@ func TestGetPins_MemberNoReadPermission(t *testing.T) { // Role 4 = Member with permissions 1635 (0x663). // Deny READ_MESSAGES on a specific channel via override. token := chTestCreateToken(t, database, "nopermuser", 4) - chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny all permissions for role 4 on this channel. - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2147483647)`, chID, ) @@ -835,9 +836,9 @@ func TestSetPinned_PinSuccessfully(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinner1", 1) - user, _ := database.GetUserByUsername("pinner1") - chID, _ := database.CreateChannel("general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "pin me", nil) + user, _ := database.GetUserByUsername(context.Background(), "pinner1") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "pin me", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusNoContent { @@ -845,7 +846,7 @@ func TestSetPinned_PinSuccessfully(t *testing.T) { } // Verify the message is actually pinned. - msg, _ := database.GetMessage(msgID) + msg, _ := database.GetMessage(context.Background(), msgID) if !msg.Pinned { t.Error("message should be pinned after POST") } @@ -855,17 +856,17 @@ func TestSetPinned_UnpinSuccessfully(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "unpinner1", 1) - user, _ := database.GetUserByUsername("unpinner1") - chID, _ := database.CreateChannel("general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "unpin me", nil) - _ = database.SetMessagePinned(msgID, true) + user, _ := database.GetUserByUsername(context.Background(), "unpinner1") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "unpin me", nil) + _ = database.SetMessagePinned(context.Background(), msgID, true) rr := chDelete(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusNoContent { t.Errorf("unpin status = %d, want 204; body: %s", rr.Code, rr.Body.String()) } - msg, _ := database.GetMessage(msgID) + msg, _ := database.GetMessage(context.Background(), msgID) if msg.Pinned { t.Error("message should not be pinned after DELETE") } @@ -875,7 +876,7 @@ func TestSetPinned_MessageNotFound(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinner2", 1) - chID, _ := database.CreateChannel("general", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/9999", chID), token) if rr.Code != http.StatusNotFound { @@ -899,9 +900,9 @@ func TestSetPinned_NoPermission(t *testing.T) { router := buildChannelRouter(database) // Member role (4) has permissions 1635 — does not include MANAGE_MESSAGES (0x2000). token := chTestCreateToken(t, database, "noperm", 4) - user, _ := database.GetUserByUsername("noperm") - chID, _ := database.CreateChannel("general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "try to pin", nil) + user, _ := database.GetUserByUsername(context.Background(), "noperm") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "try to pin", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusForbidden { @@ -913,10 +914,10 @@ func TestSetPinned_Idempotent(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinner4", 1) - user, _ := database.GetUserByUsername("pinner4") - chID, _ := database.CreateChannel("general", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "already pinned", nil) - _ = database.SetMessagePinned(msgID, true) + user, _ := database.GetUserByUsername(context.Background(), "pinner4") + chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "already pinned", nil) + _ = database.SetMessagePinned(context.Background(), msgID, true) // Pinning again should still succeed (idempotent). rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) diff --git a/Server/api/contract_test.go b/Server/api/contract_test.go index 3d2b1da1..ae59f2ee 100644 --- a/Server/api/contract_test.go +++ b/Server/api/contract_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -17,9 +18,9 @@ func TestContract_Messages_HasRequiredFields(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "contract-msg1", 1) - user, _ := database.GetUserByUsername("contract-msg1") - chID, _ := database.CreateChannel("contract-ch", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "contract test message", nil) + user, _ := database.GetUserByUsername(context.Background(), "contract-msg1") + chID, _ := database.CreateChannel(context.Background(), "contract-ch", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "contract test message", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) if rr.Code != http.StatusOK { @@ -85,10 +86,10 @@ func TestContract_Messages_ReactionsHaveMeFlag(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "contract-react1", 1) - user, _ := database.GetUserByUsername("contract-react1") - chID, _ := database.CreateChannel("react-ch", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "reaction target", nil) - _ = database.AddReaction(msgID, user.ID, "👍") + user, _ := database.GetUserByUsername(context.Background(), "contract-react1") + chID, _ := database.CreateChannel(context.Background(), "react-ch", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "reaction target", nil) + _ = database.AddReaction(context.Background(), msgID, user.ID, "👍") rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token) if rr.Code != http.StatusOK { @@ -133,9 +134,9 @@ func TestContract_Search_HasRequiredFields(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "contract-search1", 1) - user, _ := database.GetUserByUsername("contract-search1") - chID, _ := database.CreateChannel("search-ch", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "contractsearchterm in body", nil) + user, _ := database.GetUserByUsername(context.Background(), "contract-search1") + chID, _ := database.CreateChannel(context.Background(), "search-ch", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "contractsearchterm in body", nil) rr := chGet(t, router, "/api/v1/search?q=contractsearchterm", token) if rr.Code != http.StatusOK { diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go index 23aaf4ab..84488d7b 100644 --- a/Server/api/coverage_push_test.go +++ b/Server/api/coverage_push_test.go @@ -485,7 +485,7 @@ func TestCloseDM_BroadcasterUserOffline(t *testing.T) { tokenAlice := dmCreateToken(t, database, "offline_alice", 4) _ = dmCreateToken(t, database, "offline_bob", 4) - bob, _ := database.GetUserByUsername("offline_bob") + bob, _ := database.GetUserByUsername(context.Background(), "offline_bob") // Create a DM. rr := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -565,7 +565,7 @@ func TestGetMessages_InvalidLimit(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "msglimit", 1) - chID, _ := database.CreateChannel("limit-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "limit-ch", "text", "", "", 0) // Negative limit. rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=-1", chID), token) @@ -592,7 +592,7 @@ func TestGetPins_Success(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinuser", 1) - chID, _ := database.CreateChannel("pin-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "pin-ch", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token) if rr.Code != http.StatusOK { @@ -603,7 +603,7 @@ func TestGetPins_Success(t *testing.T) { func TestGetPins_Unauthorized(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) - chID, _ := database.CreateChannel("pin-unauth-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "pin-unauth-ch", "text", "", "", 0) req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/channels/%d/pins", chID), nil) req.RemoteAddr = "127.0.0.1:9999" @@ -620,7 +620,7 @@ func TestGetPins_Unauthorized(t *testing.T) { func TestSetPinned_Unauthorized(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) - chID, _ := database.CreateChannel("setpin-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "setpin-ch", "text", "", "", 0) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/channels/%d/messages/1/pin", chID), @@ -712,8 +712,8 @@ func TestListSessions_MultipleSessions(t *testing.T) { token := profileCreateToken(t, database, "multisess", 4) // Create additional session. - user, _ := database.GetUserByUsername("multisess") - _, _ = database.CreateSession(user.ID, auth.HashToken("extra-token"), "Chrome", "1.2.3.4") + user, _ := database.GetUserByUsername(context.Background(), "multisess") + _, _ = database.CreateSession(context.Background(), user.ID, auth.HashToken("extra-token"), "Chrome", "1.2.3.4") rr := getWithToken(t, router, "/api/v1/users/me/sessions", token) if rr.Code != http.StatusOK { @@ -784,7 +784,7 @@ func TestSetPinned_MessageNotFound_Push(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinmissmsg", 1) - chID, _ := database.CreateChannel("pinmiss-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "pinmiss-ch", "text", "", "", 0) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, 99999), token) if rr.Code != http.StatusNotFound { @@ -818,7 +818,7 @@ func TestSetPinned_InvalidMessageID(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinbadmsg", 1) - chID, _ := database.CreateChannel("badmsgid-ch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "badmsgid-ch", "text", "", "", 0) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/abc", chID), token) if rr.Code != http.StatusBadRequest { @@ -830,10 +830,10 @@ func TestUnpin_Success(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "unpinner", 1) - user, _ := database.GetUserByUsername("unpinner") - chID, _ := database.CreateChannel("unpin-ch", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "to unpin", nil) - _ = database.SetMessagePinned(msgID, true) + user, _ := database.GetUserByUsername(context.Background(), "unpinner") + chID, _ := database.CreateChannel(context.Background(), "unpin-ch", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "to unpin", nil) + _ = database.SetMessagePinned(context.Background(), msgID, true) rr := chDelete(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusNoContent { @@ -845,9 +845,9 @@ func TestSetPinned_MemberForbidden(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinmember", 4) - user, _ := database.GetUserByUsername("pinmember") - chID, _ := database.CreateChannel("pinforbid-ch", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID, user.ID, "cant pin", nil) + user, _ := database.GetUserByUsername(context.Background(), "pinmember") + chID, _ := database.CreateChannel(context.Background(), "pinforbid-ch", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "cant pin", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token) if rr.Code != http.StatusForbidden { @@ -859,10 +859,10 @@ func TestSetPinned_WrongChannel(t *testing.T) { database := newPinTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "pinwrongch", 1) - user, _ := database.GetUserByUsername("pinwrongch") - chID1, _ := database.CreateChannel("pin-ch1", "text", "", "", 0) - chID2, _ := database.CreateChannel("pin-ch2", "text", "", "", 0) - msgID, _ := database.CreateMessage(chID1, user.ID, "wrong channel", nil) + user, _ := database.GetUserByUsername(context.Background(), "pinwrongch") + chID1, _ := database.CreateChannel(context.Background(), "pin-ch1", "text", "", "", 0) + chID2, _ := database.CreateChannel(context.Background(), "pin-ch2", "text", "", "", 0) + msgID, _ := database.CreateMessage(context.Background(), chID1, user.ID, "wrong channel", nil) // Try to pin a message from chID1 using chID2. rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID2, msgID), token) @@ -878,11 +878,11 @@ func TestSetPinned_DMChannel_ParticipantSuccess(t *testing.T) { router := buildChannelRouter(database) tokenAlice := chTestCreateToken(t, database, "dmpin_alice", 4) _ = chTestCreateToken(t, database, "dmpin_bob", 4) - alice, _ := database.GetUserByUsername("dmpin_alice") - bob, _ := database.GetUserByUsername("dmpin_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmpin_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmpin_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) - msgID, _ := database.CreateMessage(dmCh.ID, alice.ID, "pin this dm msg", nil) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) + msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "pin this dm msg", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenAlice) if rr.Code != http.StatusNoContent { @@ -896,11 +896,11 @@ func TestSetPinned_DMChannel_NonParticipantForbidden(t *testing.T) { _ = chTestCreateToken(t, database, "dmpinforbid_alice", 4) _ = chTestCreateToken(t, database, "dmpinforbid_bob", 4) tokenCharlie := chTestCreateToken(t, database, "dmpinforbid_charlie", 4) - alice, _ := database.GetUserByUsername("dmpinforbid_alice") - bob, _ := database.GetUserByUsername("dmpinforbid_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) - msgID, _ := database.CreateMessage(dmCh.ID, alice.ID, "secret msg", nil) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) + msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "secret msg", nil) rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenCharlie) if rr.Code != http.StatusNotFound { @@ -914,9 +914,9 @@ func TestSearch_WithChannelID_Push(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "searchch", 1) - user, _ := database.GetUserByUsername("searchch") - chID, _ := database.CreateChannel("search-ch1", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "findable in channel", nil) + user, _ := database.GetUserByUsername(context.Background(), "searchch") + chID, _ := database.CreateChannel(context.Background(), "search-ch1", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "findable in channel", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=findable&channel_id=%d", chID), token) if rr.Code != http.StatusOK { @@ -1028,10 +1028,10 @@ func TestGetMessages_WithBeforeParam(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "msgbefore", 1) - user, _ := database.GetUserByUsername("msgbefore") - chID, _ := database.CreateChannel("before-ch", "text", "", "", 0) - _, _ = database.CreateMessage(chID, user.ID, "msg one", nil) - msgID2, _ := database.CreateMessage(chID, user.ID, "msg two", nil) + user, _ := database.GetUserByUsername(context.Background(), "msgbefore") + chID, _ := database.CreateChannel(context.Background(), "before-ch", "text", "", "", 0) + _, _ = database.CreateMessage(context.Background(), chID, user.ID, "msg one", nil) + msgID2, _ := database.CreateMessage(context.Background(), chID, user.ID, "msg two", nil) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, msgID2), token) if rr.Code != http.StatusOK { @@ -1043,7 +1043,7 @@ func TestGetMessages_WithCustomLimit(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "msglimitcust", 1) - chID, _ := database.CreateChannel("limitch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "limitch", "text", "", "", 0) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=5", chID), token) if rr.Code != http.StatusOK { @@ -1057,7 +1057,7 @@ func TestListChannels_MemberRole(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "memberchanlist", 4) - _, _ = database.CreateChannel("visible-ch", "text", "", "", 0) + _, _ = database.CreateChannel(context.Background(), "visible-ch", "text", "", "", 0) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { @@ -1069,7 +1069,7 @@ func TestListChannels_AdminSeesAll(t *testing.T) { database := newChannelTestDB(t) router := buildChannelRouter(database) token := chTestCreateToken(t, database, "adminchanlist", 2) - _, _ = database.CreateChannel("admin-visible-ch", "text", "", "", 0) + _, _ = database.CreateChannel(context.Background(), "admin-visible-ch", "text", "", "", 0) rr := chGet(t, router, "/api/v1/channels", token) if rr.Code != http.StatusOK { @@ -1091,10 +1091,10 @@ func TestGetMessages_DMChannel_NonParticipant(t *testing.T) { _ = chTestCreateToken(t, database, "dmmsg_alice", 4) _ = chTestCreateToken(t, database, "dmmsg_bob", 4) tokenCharlie := chTestCreateToken(t, database, "dmmsg_charlie", 4) - alice, _ := database.GetUserByUsername("dmmsg_alice") - bob, _ := database.GetUserByUsername("dmmsg_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmmsg_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmmsg_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenCharlie) if rr.Code != http.StatusNotFound { @@ -1107,10 +1107,10 @@ func TestGetMessages_DMChannel_ParticipantSuccess(t *testing.T) { router := buildChannelRouter(database) tokenAlice := chTestCreateToken(t, database, "dmmsgok_alice", 4) _ = chTestCreateToken(t, database, "dmmsgok_bob", 4) - alice, _ := database.GetUserByUsername("dmmsgok_alice") - bob, _ := database.GetUserByUsername("dmmsgok_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmmsgok_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmmsgok_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenAlice) if rr.Code != http.StatusOK { @@ -1126,10 +1126,10 @@ func TestSearch_DMChannelFilter_NonParticipant(t *testing.T) { _ = chTestCreateToken(t, database, "dmsearch_alice", 4) _ = chTestCreateToken(t, database, "dmsearch_bob", 4) tokenCharlie := chTestCreateToken(t, database, "dmsearch_charlie", 4) - alice, _ := database.GetUserByUsername("dmsearch_alice") - bob, _ := database.GetUserByUsername("dmsearch_bob") + alice, _ := database.GetUserByUsername(context.Background(), "dmsearch_alice") + bob, _ := database.GetUserByUsername(context.Background(), "dmsearch_bob") - dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID) + dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID) rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=test&channel_id=%d", dmCh.ID), tokenCharlie) if rr.Code != http.StatusForbidden { diff --git a/Server/api/diagnostics_handler_test.go b/Server/api/diagnostics_handler_test.go index d7f73b68..9d8270be 100644 --- a/Server/api/diagnostics_handler_test.go +++ b/Server/api/diagnostics_handler_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -38,10 +39,10 @@ func setupDiagnosticsRouter(t *testing.T) (http.Handler, string, *db.DB) { t.Cleanup(cleanup) // Create a user and session for authenticated requests. - uid, _ := database.CreateUser("diaguser", "$2a$12$fake", 1) + uid, _ := database.CreateUser(context.Background(), "diaguser", "$2a$12$fake", 1) token := "diagtest-token-123" hash := auth.HashToken(token) - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, uid, hash, @@ -102,12 +103,12 @@ func TestDiagnosticsConnectivity_Unauthenticated(t *testing.T) { func TestDiagnosticsConnectivity_MemberForbidden(t *testing.T) { router, _, database := setupDiagnosticsRouter(t) - uid, err := database.CreateUser("diagmember", "$2a$12$fake", int(permissions.MemberRoleID)) + uid, err := database.CreateUser(context.Background(), "diagmember", "$2a$12$fake", int(permissions.MemberRoleID)) if err != nil { t.Fatalf("CreateUser: %v", err) } token := "diagtest-member-token" - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, uid, auth.HashToken(token), diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index 1b595469..b61720c3 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -113,7 +113,7 @@ func handleListDMs(svc *service.Services) http.HandlerFunc { return } - channels, err := svc.DMs.ListDMs(user.ID) + channels, err := svc.DMs.ListDMs(r.Context(), user.ID) if err != nil { writeServiceError(w, err) return @@ -138,7 +138,7 @@ func handleCloseDM(svc *service.Services, broadcaster DMBroadcaster) http.Handle return } - if err := svc.DMs.CloseDM(user.ID, channelID); err != nil { + if err := svc.DMs.CloseDM(r.Context(), user.ID, channelID); err != nil { writeServiceError(w, err) return } @@ -191,7 +191,7 @@ func handleUnblockUser(svc *service.Services) http.HandlerFunc { return } - if err := svc.Blocks.UnblockUser(user.ID, targetID); err != nil { + if err := svc.Blocks.UnblockUser(r.Context(), user.ID, targetID); err != nil { writeServiceError(w, err) return } @@ -208,7 +208,7 @@ func handleListBlocks(svc *service.Services) http.HandlerFunc { return } - ids, err := svc.Blocks.ListBlocked(user.ID) + ids, err := svc.Blocks.ListBlocked(r.Context(), user.ID) if err != nil { writeServiceError(w, err) return diff --git a/Server/api/dm_handler_test.go b/Server/api/dm_handler_test.go index 8610f5ee..a1a8c443 100644 --- a/Server/api/dm_handler_test.go +++ b/Server/api/dm_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -154,13 +155,13 @@ func (m *mockBroadcaster) SendToUser(userID int64, msg []byte) bool { // dmCreateToken creates a user+session and returns the plaintext token. func dmCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() - _, err := database.CreateUser(username, "$2a$12$fake", roleID) + _, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID) if err != nil { t.Fatalf("CreateUser %q: %v", username, err) } token := "dmtest-token-" + username hash := auth.HashToken(token) - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`, hash, username, @@ -225,7 +226,7 @@ func TestCreateDM_Success_NewDM(t *testing.T) { tokenAlice := dmCreateToken(t, database, "alice", 4) _ = dmCreateToken(t, database, "bob", 4) - bob, _ := database.GetUserByUsername("bob") + bob, _ := database.GetUserByUsername(context.Background(), "bob") rr := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ "recipient_id": bob.ID, @@ -256,7 +257,7 @@ func TestCreateDM_Success_ExistingDM(t *testing.T) { tokenAlice := dmCreateToken(t, database, "alice2", 4) _ = dmCreateToken(t, database, "bob2", 4) - bob, _ := database.GetUserByUsername("bob2") + bob, _ := database.GetUserByUsername(context.Background(), "bob2") // First call creates the DM. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -328,7 +329,7 @@ func TestCreateDM_BadRequest_SelfDM(t *testing.T) { database := newDMTestDB(t) router := buildDMRouter(database, nil) token := dmCreateToken(t, database, "selfuser", 4) - self, _ := database.GetUserByUsername("selfuser") + self, _ := database.GetUserByUsername(context.Background(), "selfuser") rr := dmPost(t, router, "/api/v1/dms", token, map[string]any{ "recipient_id": self.ID, @@ -372,7 +373,7 @@ func TestListDMs_ReturnsOpenDMs(t *testing.T) { tokenAlice := dmCreateToken(t, database, "list_alice", 4) _ = dmCreateToken(t, database, "list_bob", 4) - bob, _ := database.GetUserByUsername("list_bob") + bob, _ := database.GetUserByUsername(context.Background(), "list_bob") // Create a DM. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -436,7 +437,7 @@ func TestCloseDM_Success(t *testing.T) { tokenAlice := dmCreateToken(t, database, "close_alice", 4) _ = dmCreateToken(t, database, "close_bob", 4) - bob, _ := database.GetUserByUsername("close_bob") + bob, _ := database.GetUserByUsername(context.Background(), "close_bob") // Create a DM. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -468,7 +469,7 @@ func TestCloseDM_Success_VerifyRemovedFromList(t *testing.T) { tokenAlice := dmCreateToken(t, database, "closelist_alice", 4) _ = dmCreateToken(t, database, "closelist_bob", 4) - bob, _ := database.GetUserByUsername("closelist_bob") + bob, _ := database.GetUserByUsername(context.Background(), "closelist_bob") // Create a DM. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -502,7 +503,7 @@ func TestCloseDM_Forbidden_NotParticipant(t *testing.T) { tokenAlice := dmCreateToken(t, database, "forbid_alice", 4) _ = dmCreateToken(t, database, "forbid_bob", 4) tokenCharlie := dmCreateToken(t, database, "forbid_charlie", 4) - bob, _ := database.GetUserByUsername("forbid_bob") + bob, _ := database.GetUserByUsername(context.Background(), "forbid_bob") // Alice creates DM with Bob. rr1 := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{ @@ -549,7 +550,7 @@ func TestCloseDM_NilBroadcaster(t *testing.T) { token := dmCreateToken(t, database, "nilbc_alice", 4) _ = dmCreateToken(t, database, "nilbc_bob", 4) - bob, _ := database.GetUserByUsername("nilbc_bob") + bob, _ := database.GetUserByUsername(context.Background(), "nilbc_bob") // Create a DM. rr1 := dmPost(t, router, "/api/v1/dms", token, map[string]any{ diff --git a/Server/api/invite_handler.go b/Server/api/invite_handler.go index 50a12914..07689f05 100644 --- a/Server/api/invite_handler.go +++ b/Server/api/invite_handler.go @@ -85,7 +85,7 @@ func handleCreateInvite(svc *service.Services) http.HandlerFunc { // handleListInvites processes GET /api/v1/invites. func handleListInvites(svc *service.Services) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - invites, err := svc.Invites.ListInvites() + invites, err := svc.Invites.ListInvites(r.Context()) if err != nil { writeServiceError(w, err) return @@ -103,7 +103,7 @@ func handleListInvites(svc *service.Services) http.HandlerFunc { func handleRevokeInvite(svc *service.Services) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { code := chi.URLParam(r, "code") - if err := svc.Invites.RevokeInvite(code); err != nil { + if err := svc.Invites.RevokeInvite(r.Context(), code); err != nil { writeServiceError(w, err) return } diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go index 8cea1344..59cd2588 100644 --- a/Server/api/invite_handler_test.go +++ b/Server/api/invite_handler_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -27,9 +28,9 @@ func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler func loginAndGetToken(t *testing.T, _ http.Handler, database *db.DB, username string, roleID int) string { t.Helper() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser(username, hash, roleID) + uid, _ := database.CreateUser(context.Background(), username, hash, roleID) token, _ := auth.GenerateToken() - _, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1") return token } @@ -104,11 +105,11 @@ func TestCreateInvite_ChannelAllowOverrideDoesNotGrant(t *testing.T) { token := loginAndGetToken(t, router, database, "overrideuser", 4) - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`); err != nil { t.Fatalf("insert channel: %v", err) } - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, ?, 0)`, permissions.ManageInvites, ); err != nil { @@ -176,7 +177,7 @@ func TestCreateInvite_CreateInviteFailure(t *testing.T) { router := buildInviteRouter(database, limiter) token := loginAndGetToken(t, router, database, "invitecreatefail", 2) - if _, err := database.Exec(`DROP TABLE invites`); err != nil { + if _, err := database.ExecContext(context.Background(), `DROP TABLE invites`); err != nil { t.Fatalf("drop invites table: %v", err) } @@ -200,7 +201,7 @@ func TestCreateInvite_GetInviteFailure(t *testing.T) { router := buildInviteRouter(database, limiter) token := loginAndGetToken(t, router, database, "invitegetfail", 2) - if _, err := database.Exec(` + if _, err := database.ExecContext(context.Background(), ` CREATE TRIGGER delete_invite_after_insert AFTER INSERT ON invites BEGIN @@ -222,7 +223,7 @@ func TestCreateInvite_GetInviteFailure(t *testing.T) { if resp["message"] != "an internal error occurred" { t.Errorf("message = %v, want an internal error occurred", resp["message"]) } - if _, err := database.Exec(`DROP TRIGGER delete_invite_after_insert`); err != nil { + if _, err := database.ExecContext(context.Background(), `DROP TRIGGER delete_invite_after_insert`); err != nil { t.Fatalf("drop trigger: %v", err) } } @@ -331,7 +332,7 @@ func TestRevokeInvite_Success(t *testing.T) { } // Verify invite is revoked. - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv == nil || !inv.Revoked { t.Error("Invite not revoked in database after DELETE") } @@ -397,7 +398,7 @@ func TestRevokeInvite_RevokeFailure(t *testing.T) { } code := created["code"].(string) - if _, err := database.Exec(` + if _, err := database.ExecContext(context.Background(), ` CREATE TRIGGER block_revoke_invite BEFORE UPDATE OF revoked ON invites BEGIN diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 38d1fbc6..7b92f023 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -42,7 +42,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } hash := auth.HashToken(token) - sess, err := database.GetSessionByTokenHash(hash) + sess, err := database.GetSessionByTokenHash(r.Context(), hash) if err != nil || sess == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -54,8 +54,11 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { // Check expiry. if auth.IsSessionExpired(sess.ExpiresAt) { // Clean up expired session in background to prevent accumulation. + // The request ctx is cancelled as soon as the 401 below is + // written, so detach cancellation: the deletion must complete. + cleanupCtx := context.WithoutCancel(r.Context()) go func(h string) { - _ = database.DeleteSession(h) + _ = database.DeleteSession(cleanupCtx, h) }(hash) writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -65,7 +68,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } // Load user. - user, err := database.GetUserByID(sess.UserID) + user, err := database.GetUserByID(r.Context(), sess.UserID) if err != nil || user == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -87,7 +90,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { // A dangling role_id returns (nil, nil) from GetRoleByID, so the nil // check is load-bearing: without it a nil role reaches the context // and every downstream permission check has to re-guard it. - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(r.Context(), user.RoleID) if err != nil || role == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -97,7 +100,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler { } // Touch session in background — non-fatal if it fails. - if err := database.TouchSession(hash); err != nil { + if err := database.TouchSession(r.Context(), hash); err != nil { slog.Warn("failed to touch session", "error", err, "user_id", user.ID) } diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 68ea2c56..7f2dfc3c 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -52,10 +52,10 @@ func withBearer(req *http.Request, token string) *http.Request { func TestAuthMiddleware_ValidToken(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("alice", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "alice", "hash", 4) token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -100,13 +100,13 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) { func TestAuthMiddleware_ExpiredSession(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("bob", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "bob", "hash", 4) token, _ := auth.GenerateToken() hash := auth.HashToken(token) // Insert an already-expired session. pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, uid, hash, "test", "127.0.0.1", pastTime, ) @@ -155,21 +155,21 @@ func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) { // users.role_id has a FK to roles(id), so the dangling row can only be // created with FK enforcement momentarily off (db.Open pins the pool to a // single connection, so the pragma applies to the inserts that follow). - if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil { t.Fatalf("disable foreign keys: %v", err) } - res, err := database.Exec( + res, err := database.ExecContext(context.Background(), `INSERT INTO users (username, password, role_id) VALUES ('dangling', '$2a$12$fake', 999)`) if err != nil { t.Fatalf("insert dangling user: %v", err) } uid, _ := res.LastInsertId() - if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil { + if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil { t.Fatalf("re-enable foreign keys: %v", err) } token, _ := auth.GenerateToken() - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, uid, auth.HashToken(token), @@ -193,10 +193,10 @@ func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) { func TestRequirePermission_Allowed(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("carol", "hash", 4) // Member role = 0x663 + uid, _ := database.CreateUser(context.Background(), "carol", "hash", 4) // Member role = 0x663 token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.SendMessages)(http.HandlerFunc(ok)), @@ -214,10 +214,10 @@ func TestRequirePermission_Allowed(t *testing.T) { func TestRequirePermission_Forbidden(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("dave", "hash", 4) // Member role = 0x663 + uid, _ := database.CreateUser(context.Background(), "dave", "hash", 4) // Member role = 0x663 token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.ManageRoles)(http.HandlerFunc(ok)), @@ -236,10 +236,10 @@ func TestRequirePermission_Forbidden(t *testing.T) { func TestRequirePermission_Administrator_Bypass(t *testing.T) { database := newAPITestDB(t) // Owner role (id=1) has permissions 0x7FFFFFFF which includes ADMINISTRATOR (0x40000000) - uid, _ := database.CreateUser("owner", "hash", 1) + uid, _ := database.CreateUser(context.Background(), "owner", "hash", 1) token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") // Any permission should pass for ADMINISTRATOR h := api.AuthMiddleware(database)( @@ -262,10 +262,10 @@ func TestRequirePermission_Administrator_Bypass(t *testing.T) { // Member holds SendMessages, which was enough to make the mask non-zero. func TestRequirePermission_MultiBitRequiresAllBits(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("multibit", "hash", 4) // Member role = 1635, has SendMessages, not ManageRoles + uid, _ := database.CreateUser(context.Background(), "multibit", "hash", 4) // Member role = 1635, has SendMessages, not ManageRoles token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.SendMessages | permissions.ManageRoles)(http.HandlerFunc(ok)), @@ -411,11 +411,11 @@ func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) { // with no expiry cannot pass the auth middleware. func TestAuthMiddleware_BannedUserBlocked(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("banneduser", "hash", 4) - _ = database.BanUser(uid, "rule violation", nil) // permanent ban + uid, _ := database.CreateUser(context.Background(), "banneduser", "hash", 4) + _ = database.BanUser(context.Background(), uid, "rule violation", nil) // permanent ban token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -433,15 +433,15 @@ func TestAuthMiddleware_BannedUserBlocked(t *testing.T) { // expired in the past can pass the auth middleware. func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("expbanned", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "expbanned", "hash", 4) // Set ban with an expiry time in the past. past := time.Now().UTC().Add(-time.Hour) - _ = database.BanUser(uid, "temp ban", &past) + _ = database.BanUser(context.Background(), uid, "temp ban", &past) token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -459,15 +459,15 @@ func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) { // temporary ban whose expiry is in the future is still blocked. func TestAuthMiddleware_ActiveTemporaryBanBlocked(t *testing.T) { database := newAPITestDB(t) - uid, _ := database.CreateUser("tempbanned", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "tempbanned", "hash", 4) // Set ban with an expiry time in the future. future := time.Now().UTC().Add(time.Hour) - _ = database.BanUser(uid, "temp ban", &future) + _ = database.BanUser(context.Background(), uid, "temp ban", &future) token, _ := auth.GenerateToken() hash := auth.HashToken(token) - _, _ = database.CreateSession(uid, hash, "test", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 4a26eec2..1d4ef805 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -187,14 +187,14 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) if !auth.CheckPassword(user.PasswordHash, req.OldPassword) { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { - limiter.Lockout(lockKey, pwConfirmLockoutDuration) + limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) } writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "incorrect password", }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) // Reject same old/new password. if req.OldPassword == req.NewPassword { @@ -228,7 +228,7 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http keepSessionID = sess.ID } - res, err := svc.Users.ChangePassword(user.ID, hash, keepSessionID) + res, err := svc.Users.ChangePassword(r.Context(), user.ID, hash, keepSessionID) if err != nil { // Only reachable when the password itself failed to commit. writeServiceError(w, err) @@ -268,7 +268,7 @@ func handleListSessions(svc *service.Services) http.HandlerFunc { return } - sessions, err := svc.Users.ListSessions(user.ID) + sessions, err := svc.Users.ListSessions(r.Context(), user.ID) if err != nil { writeServiceError(w, err) return @@ -308,7 +308,7 @@ func handleRevokeSession(svc *service.Services) http.HandlerFunc { return } - if err := svc.Users.RevokeSession(user.ID, sessionID); err != nil { + if err := svc.Users.RevokeSession(r.Context(), user.ID, sessionID); err != nil { writeServiceError(w, err) return } diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go index aeaebb00..f41642c4 100644 --- a/Server/api/profile_handler_test.go +++ b/Server/api/profile_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -28,7 +29,7 @@ func buildProfileRouter(database *db.DB) http.Handler { // profileCreateToken creates a user and session, returning the raw token. func profileCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() - uid, err := database.CreateUser(username, mustHash(t), roleID) + uid, err := database.CreateUser(context.Background(), username, mustHash(t), roleID) if err != nil { t.Fatalf("CreateUser(%s): %v", username, err) } @@ -37,7 +38,7 @@ func profileCreateToken(t *testing.T, database *db.DB, username string, roleID i t.Fatalf("GenerateToken: %v", err) } expiresAt := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z") - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), "INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)", uid, auth.HashToken(token), "TestAgent", "127.0.0.1", expiresAt, ) @@ -183,12 +184,12 @@ func TestChangePassword_RevokesOtherSessions(t *testing.T) { // Create user with two sessions. token1 := profileCreateToken(t, database, "pw-revoke", 4) - user, _ := database.GetUserByUsername("pw-revoke") + user, _ := database.GetUserByUsername(context.Background(), "pw-revoke") // Create a second session for the same user. token2, _ := auth.GenerateToken() expiresAt := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z") - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), "INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)", user.ID, auth.HashToken(token2), "OtherDevice", "10.0.0.1", expiresAt, ) @@ -203,13 +204,13 @@ func TestChangePassword_RevokesOtherSessions(t *testing.T) { } // token1 (current session) should still work. - sess1, _ := database.GetSessionByTokenHash(auth.HashToken(token1)) + sess1, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token1)) if sess1 == nil { t.Error("current session should survive password change") } // token2 (other session) should be revoked. - sess2, _ := database.GetSessionByTokenHash(auth.HashToken(token2)) + sess2, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token2)) if sess2 != nil { t.Error("other session should be revoked after password change") } @@ -314,8 +315,8 @@ func TestRevokeSession_Success(t *testing.T) { token := profileCreateToken(t, database, "revoke", 4) // Create a second session to revoke. - user, _ := database.GetUserByUsername("revoke") - secondSessID, _ := database.CreateSession(user.ID, auth.HashToken("second-tok"), "Firefox", "1.2.3.4") + user, _ := database.GetUserByUsername(context.Background(), "revoke") + secondSessID, _ := database.CreateSession(context.Background(), user.ID, auth.HashToken("second-tok"), "Firefox", "1.2.3.4") rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", secondSessID), token) @@ -342,8 +343,8 @@ func TestRevokeSession_OtherUsersSession(t *testing.T) { token := profileCreateToken(t, database, "revokeother", 4) // Create another user with a session. - otherUID, _ := database.CreateUser("victim", mustHash(t), 4) - otherSessID, _ := database.CreateSession(otherUID, auth.HashToken("victim-tok"), "Safari", "9.8.7.6") + otherUID, _ := database.CreateUser(context.Background(), "victim", mustHash(t), 4) + otherSessID, _ := database.CreateSession(context.Background(), otherUID, auth.HashToken("victim-tok"), "Safari", "9.8.7.6") rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", otherSessID), token) @@ -358,8 +359,8 @@ func TestRevokeSession_CurrentSession(t *testing.T) { token := profileCreateToken(t, database, "revokeself", 4) // Find the current session ID. - user, _ := database.GetUserByUsername("revokeself") - sessions, _ := database.ListUserSessions(user.ID) + user, _ := database.GetUserByUsername(context.Background(), "revokeself") + sessions, _ := database.ListUserSessions(context.Background(), user.ID) if len(sessions) == 0 { t.Fatal("expected at least 1 session") } diff --git a/Server/api/totp_handler.go b/Server/api/totp_handler.go index f108b11d..e15fd413 100644 --- a/Server/api/totp_handler.go +++ b/Server/api/totp_handler.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "fmt" @@ -81,7 +82,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - user, err := database.GetUserByID(challenge.UserID) + user, err := database.GetUserByID(r.Context(), challenge.UserID) if err != nil || user == nil || user.TOTPSecret == nil { writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -111,7 +112,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - limiter.Reset(totpRateLimitKey) + limiter.Reset(r.Context(), totpRateLimitKey) if _, ok := partialStore.Consume(partialToken); !ok { writeJSON(w, http.StatusUnauthorized, errorResponse{ @@ -121,7 +122,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - token, err := issueSession(database, user.ID, challenge.Device, challenge.IP) + token, err := issueSession(r.Context(), database, user.ID, challenge.Device, challenge.IP) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -131,7 +132,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi } slog.Info("totp verified", "user_id", user.ID, "ip", challenge.IP) - db.WriteAudit(database, user.ID, "totp_verified", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "totp_verified", "user", user.ID, "two-factor verification completed from "+challenge.IP) writeJSON(w, http.StatusOK, authSuccessResponse{ @@ -182,7 +183,7 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLim failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { - limiter.Lockout(lockKey, pwConfirmLockoutDuration) + limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", @@ -190,7 +191,7 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLim }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) secret, err := auth.GenerateTOTPSecret() if err != nil { @@ -241,7 +242,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { - limiter.Lockout(lockKey, pwConfirmLockoutDuration) + limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", @@ -249,7 +250,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) secret, ok := pendingStore.Lookup(user.ID) if !ok { @@ -278,7 +279,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use return } - if err := database.UpdateUserTOTPSecret(user.ID, &encryptedSecret); err != nil { + if err := database.UpdateUserTOTPSecret(r.Context(), user.ID, &encryptedSecret); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to enable two-factor authentication", @@ -289,14 +290,16 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use // BUG-108: Revoke all other sessions after 2FA state change. if sess, ok := r.Context().Value(SessionKey).(*db.Session); ok && sess != nil { - n, _ := database.DeleteOtherSessions(user.ID, sess.ID) + // Security tail of the 2FA change: once the secret update committed, + // revoking the other sessions must not be aborted by a dead request. + n, _ := database.DeleteOtherSessions(context.WithoutCancel(r.Context()), user.ID, sess.ID) if n > 0 { slog.Info("revoked other sessions after totp enable", "user_id", user.ID, "revoked", n) } } slog.Info("totp enabled", "user_id", user.ID) - db.WriteAudit(database, user.ID, "totp_enabled", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "totp_enabled", "user", user.ID, "two-factor authentication enrolled") w.WriteHeader(http.StatusNoContent) @@ -335,7 +338,7 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim failKey := fmt.Sprintf("pw_confirm_fail:%d", user.ID) if err := requirePasswordConfirmation(user, req.Password); err != nil { if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) { - limiter.Lockout(lockKey, pwConfirmLockoutDuration) + limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration) } writeJSON(w, http.StatusBadRequest, errorResponse{ Error: "INVALID_INPUT", @@ -343,9 +346,9 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim }) return } - limiter.Reset(failKey) + limiter.Reset(r.Context(), failKey) - require2FA, err := isRequire2FAEnabled(database) + require2FA, err := isRequire2FAEnabled(r.Context(), database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", @@ -362,7 +365,7 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim } pendingStore.Delete(user.ID) - if err := database.UpdateUserTOTPSecret(user.ID, nil); err != nil { + if err := database.UpdateUserTOTPSecret(r.Context(), user.ID, nil); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to disable two-factor authentication", @@ -372,14 +375,16 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim // BUG-108: Revoke all other sessions after 2FA state change. if sess, ok := r.Context().Value(SessionKey).(*db.Session); ok && sess != nil { - n, _ := database.DeleteOtherSessions(user.ID, sess.ID) + // Security tail of the 2FA change: once the secret update committed, + // revoking the other sessions must not be aborted by a dead request. + n, _ := database.DeleteOtherSessions(context.WithoutCancel(r.Context()), user.ID, sess.ID) if n > 0 { slog.Info("revoked other sessions after totp disable", "user_id", user.ID, "revoked", n) } } slog.Info("totp disabled", "user_id", user.ID) - db.WriteAudit(database, user.ID, "totp_disabled", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "totp_disabled", "user", user.ID, "two-factor authentication disabled") w.WriteHeader(http.StatusNoContent) diff --git a/Server/api/totp_handler_test.go b/Server/api/totp_handler_test.go index 2723c8b2..84cb64bc 100644 --- a/Server/api/totp_handler_test.go +++ b/Server/api/totp_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -22,8 +23,8 @@ func TestVerifyTOTP_Success(t *testing.T) { // Create user with TOTP enabled. secret, _ := auth.GenerateTOTPSecret() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser("totpuser", hash, 4) - _ = database.UpdateUserTOTPSecret(uid, &secret) + uid, _ := database.CreateUser(context.Background(), "totpuser", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) // Login should return requires_2fa + partial_token. rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -70,8 +71,8 @@ func TestVerifyTOTP_InvalidCode(t *testing.T) { secret, _ := auth.GenerateTOTPSecret() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser("totpuser2", hash, 4) - _ = database.UpdateUserTOTPSecret(uid, &secret) + uid, _ := database.CreateUser(context.Background(), "totpuser2", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) // Login to get partial token. rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ @@ -122,8 +123,8 @@ func TestVerifyTOTP_MalformedBody(t *testing.T) { // Need a valid partial token to get past the token check. secret, _ := auth.GenerateTOTPSecret() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser("totpuser3", hash, 4) - _ = database.UpdateUserTOTPSecret(uid, &secret) + uid, _ := database.CreateUser(context.Background(), "totpuser3", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ "username": "totpuser3", @@ -154,8 +155,8 @@ func TestVerifyTOTP_ReplayProtection(t *testing.T) { secret, _ := auth.GenerateTOTPSecret() hash, _ := auth.HashPassword("Password1!") - uid, _ := database.CreateUser("totpuser4", hash, 4) - _ = database.UpdateUserTOTPSecret(uid, &secret) + uid, _ := database.CreateUser(context.Background(), "totpuser4", hash, 4) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC()) @@ -271,7 +272,7 @@ func TestConfirmTOTP_Success(t *testing.T) { } // Verify TOTP is now stored on user. - user, _ := database.GetUserByUsername("confirmuser") + user, _ := database.GetUserByUsername(context.Background(), "confirmuser") if user == nil { t.Fatal("user not found after confirm") } @@ -392,7 +393,7 @@ func TestDisableTOTP_BlockedByServerPolicy(t *testing.T) { token := loginAndGetToken(t, router, database, "disableuser3", 4) // Enable require_2fa server policy. - _, _ = database.Exec(`INSERT OR REPLACE INTO settings (key, value) VALUES ('require_2fa', '1')`) + _, _ = database.ExecContext(context.Background(), `INSERT OR REPLACE INTO settings (key, value) VALUES ('require_2fa', '1')`) rr := deleteWithToken(t, router, "/api/v1/users/me/totp", token, map[string]string{"password": "Password1!"}) diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 016106c1..132c31a7 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -186,7 +186,7 @@ func handleUpload(database *db.DB, store *storage.Storage, limiter *auth.RateLim // Insert attachment record in DB (unlinked — message_id is NULL). user, _ = r.Context().Value(UserKey).(*db.User) safeFilename := sanitizeUploadFilename(header.Filename) - if err := database.CreateAttachment(fileID, user.ID, safeFilename, fileID, mime, writtenBytes, width, height); err != nil { + if err := database.CreateAttachment(r.Context(), fileID, user.ID, safeFilename, fileID, mime, writtenBytes, width, height); err != nil { // Clean up stored file on DB failure. _ = store.Delete(fileID) slog.Error("failed to create attachment record", "error", err) @@ -223,7 +223,7 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s role, _ := r.Context().Value(RoleKey).(*db.Role) // Look up attachment metadata with channel context. - aa, err := database.GetAttachmentWithChannel(fileID) + aa, err := database.GetAttachmentWithChannel(r.Context(), fileID) if err != nil { slog.Error("failed to look up attachment", "id", fileID, "error", err) writeJSON(w, http.StatusInternalServerError, errorResponse{ @@ -269,7 +269,7 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s }) return } - ok, dmErr := database.IsDMParticipant(user.ID, *aa.ChannelID) + ok, dmErr := database.IsDMParticipant(r.Context(), user.ID, *aa.ChannelID) if dmErr != nil || !ok { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", @@ -277,7 +277,7 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s }) return } - } else if user == nil || !permSvc.HasChannelPerm(user.ID, *aa.ChannelID, permissions.ReadMessages) { + } else if user == nil || !permSvc.HasChannelPerm(r.Context(), user.ID, *aa.ChannelID, permissions.ReadMessages) { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "you do not have access to this file", diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index 20251174..39bc6131 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -2,6 +2,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "fmt" "image" @@ -177,13 +178,13 @@ func buildUploadRouterWithLimiter(database *db.DB, store *storage.Storage, limit // uploadCreateToken creates a user+session and returns the plaintext token. func uploadCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() - _, err := database.CreateUser(username, "$2a$12$fake", roleID) + _, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID) if err != nil { t.Fatalf("CreateUser %q: %v", username, err) } token := "upload-test-token-" + username hash := auth.HashToken(token) - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`, hash, username, @@ -320,7 +321,7 @@ func TestUpload_Success_TextFile(t *testing.T) { } // Verify attachment record was created in DB. - att, err := database.GetAttachmentByID(resp["id"].(string)) + att, err := database.GetAttachmentByID(context.Background(), resp["id"].(string)) if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -557,7 +558,7 @@ func TestUpload_DBCreateAttachmentFailureDeletesStoredFile(t *testing.T) { router := buildUploadRouter(database, store, nil) token := uploadCreateToken(t, database, "dbfailupload", 1) - if _, err := database.Exec(`DROP TABLE attachments`); err != nil { + if _, err := database.ExecContext(context.Background(), `DROP TABLE attachments`); err != nil { t.Fatalf("drop attachments table: %v", err) } @@ -604,7 +605,7 @@ func TestUpload_SanitizesReservedFilenameToUnnamed(t *testing.T) { t.Fatalf("filename = %v, want unnamed", resp["filename"]) } - att, err := database.GetAttachmentByID(resp["id"].(string)) + att, err := database.GetAttachmentByID(context.Background(), resp["id"].(string)) if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -633,7 +634,7 @@ func TestUpload_SuccessfulUploadCreatesDBRecord(t *testing.T) { _ = json.NewDecoder(rr.Body).Decode(&resp) fileID := resp["id"].(string) - att, err := database.GetAttachmentByID(fileID) + att, err := database.GetAttachmentByID(context.Background(), fileID) if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -1159,20 +1160,20 @@ func TestServeFile_LinkedToGuildChannel_MemberWithPerm(t *testing.T) { fileID := resp["id"].(string) // Create a guild channel and link the attachment via a message. - _, err := database.Exec(`INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`) + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'general', 'text')`) if err != nil { t.Fatalf("insert channel: %v", err) } // Get the uploader's user ID. var userID int64 - if err := database.QueryRow(`SELECT id FROM users WHERE username = 'guildmember'`).Scan(&userID); err != nil { + if err := database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'guildmember'`).Scan(&userID); err != nil { t.Fatalf("get user id: %v", err) } - _, err = database.Exec(`INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, userID) + _, err = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, userID) if err != nil { t.Fatalf("insert message: %v", err) } - _, err = database.Exec(`UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) + _, err = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) if err != nil { t.Fatalf("link attachment: %v", err) } @@ -1202,24 +1203,24 @@ func TestServeFile_LinkedToGuildChannel_MemberWithoutPerm(t *testing.T) { fileID := resp["id"].(string) // Create channel and link. - _, err := database.Exec(`INSERT INTO channels (id, name, type) VALUES (1, 'secret', 'text')`) + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'secret', 'text')`) if err != nil { t.Fatalf("insert channel: %v", err) } var uploaderID int64 - if err := database.QueryRow(`SELECT id FROM users WHERE username = 'guilduploader2'`).Scan(&uploaderID); err != nil { + if err := database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'guilduploader2'`).Scan(&uploaderID); err != nil { t.Fatalf("get user id: %v", err) } - _, err = database.Exec(`INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, uploaderID) + _, err = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'test')`, uploaderID) if err != nil { t.Fatalf("insert message: %v", err) } - _, err = database.Exec(`UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) + _, err = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) if err != nil { t.Fatalf("link attachment: %v", err) } // Deny ReadMessages (0x0002) for role 4 (Member) on channel 1. - _, err = database.Exec(`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, 0, 2)`) + _, err = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (1, 4, 0, 2)`) if err != nil { t.Fatalf("insert channel_override: %v", err) } @@ -1249,17 +1250,17 @@ func TestServeFile_LinkedToDM_ParticipantAllowed(t *testing.T) { fileID := resp["id"].(string) // Create DM channel, add participants, link attachment. - _, err := database.Exec(`INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`) + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`) if err != nil { t.Fatalf("insert channel: %v", err) } var aliceID, bobID int64 - _ = database.QueryRow(`SELECT id FROM users WHERE username = 'dmalice'`).Scan(&aliceID) - _ = database.QueryRow(`SELECT id FROM users WHERE username = 'dmbob'`).Scan(&bobID) - _, _ = database.Exec(`INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, aliceID) - _, _ = database.Exec(`INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, bobID) - _, _ = database.Exec(`INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, aliceID) - _, _ = database.Exec(`UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) + _ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmalice'`).Scan(&aliceID) + _ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmbob'`).Scan(&bobID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, aliceID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, bobID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, aliceID) + _, _ = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) // DM participant can access. rr2 := doServeFile(t, router, fileID, token1, nil) @@ -1287,17 +1288,17 @@ func TestServeFile_LinkedToDM_NonParticipantForbidden(t *testing.T) { fileID := resp["id"].(string) // Create DM channel with two participants (not the outsider). - _, err := database.Exec(`INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`) + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type) VALUES (1, 'dm-1', 'dm')`) if err != nil { t.Fatalf("insert channel: %v", err) } var ownerID, partnerID int64 - _ = database.QueryRow(`SELECT id FROM users WHERE username = 'dmowner'`).Scan(&ownerID) - _ = database.QueryRow(`SELECT id FROM users WHERE username = 'dmpartner'`).Scan(&partnerID) - _, _ = database.Exec(`INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, ownerID) - _, _ = database.Exec(`INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, partnerID) - _, _ = database.Exec(`INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, ownerID) - _, _ = database.Exec(`UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) + _ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmowner'`).Scan(&ownerID) + _ = database.QueryRowContext(context.Background(), `SELECT id FROM users WHERE username = 'dmpartner'`).Scan(&partnerID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, ownerID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO dm_participants (user_id, channel_id) VALUES (?, 1)`, partnerID) + _, _ = database.ExecContext(context.Background(), `INSERT INTO messages (id, channel_id, user_id, content) VALUES (1, 1, ?, 'hi')`, ownerID) + _, _ = database.ExecContext(context.Background(), `UPDATE attachments SET message_id = 1 WHERE id = ?`, fileID) // Non-participant gets 403. rr2 := doServeFile(t, router, fileID, outsiderToken, nil) diff --git a/Server/auth/ratelimit.go b/Server/auth/ratelimit.go index 4a838b0a..bfa7d11b 100644 --- a/Server/auth/ratelimit.go +++ b/Server/auth/ratelimit.go @@ -1,6 +1,7 @@ package auth import ( + "context" "time" "github.com/owncord/server/syncutil" @@ -20,11 +21,11 @@ type lockoutEntry struct { // When provided, lockouts survive server restarts. The interface uses only // stdlib types to avoid circular dependencies between packages. type LockoutPersister interface { - UpsertLockout(key string, expiresAt time.Time) error - DeleteLockout(key string) error - CleanupExpiredLockouts() error + UpsertLockout(ctx context.Context, key string, expiresAt time.Time) error + DeleteLockout(ctx context.Context, key string) error + CleanupExpiredLockouts(ctx context.Context) error // LoadActiveLockouts returns (keys, expiresAt) slices of equal length. - LoadActiveLockouts() (keys []string, expiresAt []time.Time, err error) + LoadActiveLockouts(ctx context.Context) (keys []string, expiresAt []time.Time, err error) } // RateLimiter is an in-memory, thread-safe sliding-window rate limiter with @@ -58,8 +59,9 @@ func NewPersistentRateLimiter(store LockoutPersister) *RateLimiter { lockouts: make(map[string]*lockoutEntry), store: store, } - // Load surviving lockouts from the store. - if keys, expiresAt, err := store.LoadActiveLockouts(); err == nil { + // Load surviving lockouts from the store. Constructor runs at startup + // with no request in flight, so background context. + if keys, expiresAt, err := store.LoadActiveLockouts(context.Background()); err == nil { for i, key := range keys { rl.lockouts[key] = &lockoutEntry{expiresAt: expiresAt[i]} } @@ -111,14 +113,16 @@ func (r *RateLimiter) Allow(key string, limit int, window time.Duration) bool { // Lockout prevents any requests from key for duration regardless of the // sliding-window counter. When a LockoutStore is configured, the lockout -// is persisted so it survives server restarts. -func (r *RateLimiter) Lockout(key string, duration time.Duration) { +// is persisted so it survives server restarts. The persist write must land +// once the lockout is decided, so the caller's cancellation is detached +// (WithoutCancel) rather than aborting the write mid-request. +func (r *RateLimiter) Lockout(ctx context.Context, key string, duration time.Duration) { r.mu.Lock() defer r.mu.Unlock() expiresAt := time.Now().Add(duration) r.lockouts[key] = &lockoutEntry{expiresAt: expiresAt} if r.store != nil { - _ = r.store.UpsertLockout(key, expiresAt) + _ = r.store.UpsertLockout(context.WithoutCancel(ctx), key, expiresAt) } } @@ -170,13 +174,14 @@ func (r *RateLimiter) Check(key string, limit int, window time.Duration) bool { } // Reset clears all rate-limit state (timestamps and lockout) for key. -func (r *RateLimiter) Reset(key string) { +// Like Lockout, the store delete must complete once decided (WithoutCancel). +func (r *RateLimiter) Reset(ctx context.Context, key string) { r.mu.Lock() defer r.mu.Unlock() delete(r.windows, key) delete(r.lockouts, key) if r.store != nil { - _ = r.store.DeleteLockout(key) + _ = r.store.DeleteLockout(context.WithoutCancel(ctx), key) } } @@ -217,7 +222,8 @@ func (r *RateLimiter) Cleanup(maxWindow time.Duration) { } if r.store != nil { - _ = r.store.CleanupExpiredLockouts() + // Runs from the StartCleanup background goroutine — no request ctx. + _ = r.store.CleanupExpiredLockouts(context.Background()) } } diff --git a/Server/auth/ratelimit_cleanup_test.go b/Server/auth/ratelimit_cleanup_test.go index c7af31c6..c5ccba3e 100644 --- a/Server/auth/ratelimit_cleanup_test.go +++ b/Server/auth/ratelimit_cleanup_test.go @@ -1,6 +1,7 @@ package auth_test import ( + "context" "testing" "time" @@ -36,7 +37,7 @@ func TestCleanup_RemovesExpiredWindows(t *testing.T) { func TestCleanup_RemovesExpiredLockouts(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("stale-lockout", 20*time.Millisecond) + rl.Lockout(context.Background(), "stale-lockout", 20*time.Millisecond) time.Sleep(40 * time.Millisecond) rl.Cleanup(15 * time.Minute) @@ -69,7 +70,7 @@ func TestCleanup_PreservesActiveWindows(t *testing.T) { func TestCleanup_PreservesActiveLockouts(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("live-lockout", time.Hour) + rl.Lockout(context.Background(), "live-lockout", time.Hour) rl.Cleanup(15 * time.Minute) @@ -92,14 +93,14 @@ func TestCleanup_MixedEntries(t *testing.T) { // Stale window entry — its timestamp will be older than shortWindow. rl.Allow("stale", 10, shortWindow) // Stale lockout — expires in shortWindow. - rl.Lockout("stale-lock", shortWindow) + rl.Lockout(context.Background(), "stale-lock", shortWindow) // Wait until the stale timestamps fall outside shortWindow. time.Sleep(shortWindow + 10*time.Millisecond) // Active entries added AFTER the sleep — their timestamps are fresh. rl.Allow("active", 10, time.Hour) - rl.Lockout("live-lock", time.Hour) + rl.Lockout(context.Background(), "live-lock", time.Hour) // Cleanup with shortWindow: "stale" was recorded before the cutoff, so it // is evicted. "active" was just recorded, so it is kept. @@ -143,8 +144,8 @@ func TestLen_AfterAllows(t *testing.T) { // active lockout entries. func TestLen_AfterLockouts(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("x", time.Hour) - rl.Lockout("y", time.Hour) + rl.Lockout(context.Background(), "x", time.Hour) + rl.Lockout(context.Background(), "y", time.Hour) _, locks := rl.Len() if locks != 2 { diff --git a/Server/auth/ratelimit_test.go b/Server/auth/ratelimit_test.go index 1e557b40..7cd1f92e 100644 --- a/Server/auth/ratelimit_test.go +++ b/Server/auth/ratelimit_test.go @@ -1,6 +1,7 @@ package auth_test import ( + "context" "testing" "time" @@ -69,7 +70,7 @@ func TestRateLimiter_DifferentKeysIndependent(t *testing.T) { func TestRateLimiter_LockoutEnforced(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("keyLock", time.Hour) + rl.Lockout(context.Background(), "keyLock", time.Hour) if !rl.IsLockedOut("keyLock") { t.Error("IsLockedOut() = false after Lockout(), want true") } @@ -77,7 +78,7 @@ func TestRateLimiter_LockoutEnforced(t *testing.T) { func TestRateLimiter_LockoutExpires(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("keyExp", 30*time.Millisecond) + rl.Lockout(context.Background(), "keyExp", 30*time.Millisecond) time.Sleep(50 * time.Millisecond) if rl.IsLockedOut("keyExp") { t.Error("IsLockedOut() = true after lockout expired, want false") @@ -95,7 +96,7 @@ func TestRateLimiter_Reset(t *testing.T) { rl := auth.NewRateLimiter() rl.Allow("keyR", 1, time.Second) rl.Allow("keyR", 1, time.Second) // now blocked - rl.Reset("keyR") + rl.Reset(context.Background(), "keyR") if !rl.Allow("keyR", 1, time.Second) { t.Error("Allow() = false after Reset(), want true") } @@ -103,7 +104,7 @@ func TestRateLimiter_Reset(t *testing.T) { func TestRateLimiter_LockoutBlocksAllow(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("keyLB", time.Hour) + rl.Lockout(context.Background(), "keyLB", time.Hour) // Even under normal limit, lockout should block if rl.Allow("keyLB", 100, time.Second) { t.Error("Allow() = true for locked-out key, want false") @@ -161,7 +162,7 @@ func TestRateLimiter_Check_AtLimit(t *testing.T) { func TestRateLimiter_Check_RespectsLockout(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("checkLocked", time.Hour) + rl.Lockout(context.Background(), "checkLocked", time.Hour) if rl.Check("checkLocked", 100, time.Second) { t.Error("Check() = true for locked-out key, want false") } @@ -169,7 +170,7 @@ func TestRateLimiter_Check_RespectsLockout(t *testing.T) { func TestRateLimiter_Check_LockoutExpired(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("checkExpLock", 10*time.Millisecond) + rl.Lockout(context.Background(), "checkExpLock", 10*time.Millisecond) time.Sleep(30 * time.Millisecond) if !rl.Check("checkExpLock", 5, time.Second) { t.Error("Check() = false after lockout expired, want true") @@ -221,11 +222,11 @@ func TestRateLimiter_ConcurrentHammering(t *testing.T) { func TestRateLimiter_ResetClearsLockout(t *testing.T) { rl := auth.NewRateLimiter() - rl.Lockout("resetLock", time.Hour) + rl.Lockout(context.Background(), "resetLock", time.Hour) if !rl.IsLockedOut("resetLock") { t.Fatal("precondition: key should be locked out") } - rl.Reset("resetLock") + rl.Reset(context.Background(), "resetLock") if rl.IsLockedOut("resetLock") { t.Error("Reset() should clear lockout, but key is still locked out") } diff --git a/Server/db/account_test.go b/Server/db/account_test.go index ff41cfb1..e0654d6d 100644 --- a/Server/db/account_test.go +++ b/Server/db/account_test.go @@ -83,7 +83,7 @@ func TestDeleteAccount_AnonymisesUsername(t *testing.T) { t.Fatalf("DeleteAccount: %v", err) } - user, err := database.GetUserByID(userID) + user, err := database.GetUserByID(context.Background(), userID) if err != nil { t.Fatalf("GetUserByID after delete: %v", err) } @@ -100,7 +100,7 @@ func TestDeleteAccount_ClearsPassword(t *testing.T) { database.DeleteAccount(context.Background(), userID) //nolint:errcheck - user, _ := database.GetUserByID(userID) + user, _ := database.GetUserByID(context.Background(), userID) if user.PasswordHash != "" { t.Errorf("PasswordHash = %q, want empty", user.PasswordHash) } @@ -111,11 +111,11 @@ func TestDeleteAccount_ClearsAvatarAndTOTP(t *testing.T) { userID := seedUser(t, database, "charlie") // Set avatar and TOTP before deletion. - database.Exec("UPDATE users SET avatar = 'pic.png', totp_secret = 'SECRET' WHERE id = ?", userID) //nolint:errcheck + database.ExecContext(context.Background(), "UPDATE users SET avatar = 'pic.png', totp_secret = 'SECRET' WHERE id = ?", userID) //nolint:errcheck database.DeleteAccount(context.Background(), userID) //nolint:errcheck - user, _ := database.GetUserByID(userID) + user, _ := database.GetUserByID(context.Background(), userID) if user.Avatar != nil { t.Errorf("Avatar = %v, want nil", user.Avatar) } @@ -130,7 +130,7 @@ func TestDeleteAccount_SetsBannedAndOffline(t *testing.T) { database.DeleteAccount(context.Background(), userID) //nolint:errcheck - user, _ := database.GetUserByID(userID) + user, _ := database.GetUserByID(context.Background(), userID) if !user.Banned { t.Error("Banned should be true after deletion") } @@ -146,7 +146,7 @@ func TestDeleteAccount_DeletesSessions(t *testing.T) { userID := seedUser(t, database, "eve") // Insert a session directly. - database.Exec( + database.ExecContext(context.Background(), "INSERT INTO sessions (user_id, token, expires_at) VALUES (?, 'tok123', datetime('now', '+1 day'))", userID, ) //nolint:errcheck @@ -154,7 +154,7 @@ func TestDeleteAccount_DeletesSessions(t *testing.T) { database.DeleteAccount(context.Background(), userID) //nolint:errcheck var count int - database.QueryRow("SELECT COUNT(*) FROM sessions WHERE user_id = ?", userID).Scan(&count) //nolint:errcheck + database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM sessions WHERE user_id = ?", userID).Scan(&count) //nolint:errcheck if count != 0 { t.Errorf("sessions count = %d, want 0", count) } @@ -165,11 +165,11 @@ func TestDeleteAccount_SoftDeletesMessages(t *testing.T) { userID := seedUser(t, database, "frank") chID := seedChannel(t, database, "general") - msgID, _ := database.CreateMessage(chID, userID, "hello world", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hello world", nil) database.DeleteAccount(context.Background(), userID) //nolint:errcheck - msg, err := database.GetMessage(msgID) + msg, err := database.GetMessage(context.Background(), msgID) if err != nil { t.Fatalf("GetMessage after delete: %v", err) } @@ -194,7 +194,7 @@ func TestDeleteAccount_NonexistentUser(t *testing.T) { func setRole(t *testing.T, database *db.DB, userID, roleID int64) { t.Helper() - if _, err := database.Exec("UPDATE users SET role_id = ? WHERE id = ?", roleID, userID); err != nil { + if _, err := database.ExecContext(context.Background(), "UPDATE users SET role_id = ? WHERE id = ?", roleID, userID); err != nil { t.Fatalf("setRole(%d, %d): %v", userID, roleID, err) } } diff --git a/Server/db/admin_queries.go b/Server/db/admin_queries.go index fcf47c47..5d460e20 100644 --- a/Server/db/admin_queries.go +++ b/Server/db/admin_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -13,8 +14,8 @@ import ( // ─── Setup ─────────────────────────────────────────────────────────────────── // UserCount returns the total number of registered users. -func (d *DB) UserCount() (int64, error) { - count, err := d.q.UserCount(dbCtx()) +func (d *DB) UserCount(ctx context.Context) (int64, error) { + count, err := d.q.UserCount(ctx) if err != nil { return 0, fmt.Errorf("UserCount: %w", err) } @@ -26,20 +27,20 @@ func (d *DB) UserCount() (int64, error) { // GetServerStats returns aggregate counts for the admin dashboard. // DBSizeBytes is 0 for in-memory databases (page_count * page_size returns // a meaningful value only for file-backed databases). -func (d *DB) GetServerStats() (*ServerStats, error) { +func (d *DB) GetServerStats(ctx context.Context) (*ServerStats, error) { stats := &ServerStats{} var err error - if stats.UserCount, err = d.q.CountUsers(dbCtx()); err != nil { + if stats.UserCount, err = d.q.CountUsers(ctx); err != nil { return nil, fmt.Errorf("GetServerStats users: %w", err) } - if stats.MessageCount, err = d.q.CountActiveMessages(dbCtx()); err != nil { + if stats.MessageCount, err = d.q.CountActiveMessages(ctx); err != nil { return nil, fmt.Errorf("GetServerStats messages: %w", err) } - if stats.ChannelCount, err = d.q.CountChannels(dbCtx()); err != nil { + if stats.ChannelCount, err = d.q.CountChannels(ctx); err != nil { return nil, fmt.Errorf("GetServerStats channels: %w", err) } - if stats.InviteCount, err = d.q.CountActiveInvites(dbCtx()); err != nil { + if stats.InviteCount, err = d.q.CountActiveInvites(ctx); err != nil { return nil, fmt.Errorf("GetServerStats invites: %w", err) } @@ -47,10 +48,10 @@ func (d *DB) GetServerStats() (*ServerStats, error) { // expressible as sqlc queries, so they stay on the raw connection. // For :memory: databases this still works (returns the in-memory size). var pageCount, pageSize int64 - if err := d.sqlDB.QueryRow(`PRAGMA page_count`).Scan(&pageCount); err != nil { + if err := d.sqlDB.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err != nil { return nil, fmt.Errorf("GetServerStats page_count: %w", err) } - if err := d.sqlDB.QueryRow(`PRAGMA page_size`).Scan(&pageSize); err != nil { + if err := d.sqlDB.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err != nil { return nil, fmt.Errorf("GetServerStats page_size: %w", err) } stats.DBSizeBytes = pageCount * pageSize @@ -62,8 +63,8 @@ func (d *DB) GetServerStats() (*ServerStats, error) { // ListAllUsers returns users joined with their role name, ordered by ID. // limit=0 returns no rows. -func (d *DB) ListAllUsers(limit, offset int) ([]UserWithRole, error) { - rows, err := d.q.ListAllUsers(dbCtx(), dbgen.ListAllUsersParams{ +func (d *DB) ListAllUsers(ctx context.Context, limit, offset int) ([]UserWithRole, error) { + rows, err := d.q.ListAllUsers(ctx, dbgen.ListAllUsersParams{ Limit: int64(limit), Offset: int64(offset), }) @@ -92,8 +93,8 @@ func (d *DB) ListAllUsers(limit, offset int) ([]UserWithRole, error) { } // UpdateUserRole changes the role_id of a user. -func (d *DB) UpdateUserRole(userID, roleID int64) error { - if err := d.q.UpdateUserRole(dbCtx(), dbgen.UpdateUserRoleParams{ +func (d *DB) UpdateUserRole(ctx context.Context, userID, roleID int64) error { + if err := d.q.UpdateUserRole(ctx, dbgen.UpdateUserRoleParams{ RoleID: roleID, ID: userID, }); err != nil { @@ -103,16 +104,16 @@ func (d *DB) UpdateUserRole(userID, roleID int64) error { } // ForceLogoutUser deletes all sessions for the given user ID. -func (d *DB) ForceLogoutUser(userID int64) error { - if err := d.q.ForceLogoutUser(dbCtx(), userID); err != nil { +func (d *DB) ForceLogoutUser(ctx context.Context, userID int64) error { + if err := d.q.ForceLogoutUser(ctx, userID); err != nil { return fmt.Errorf("ForceLogoutUser: %w", err) } return nil } // GetUserSessions returns all active sessions for the given user ID. -func (d *DB) GetUserSessions(userID int64) ([]Session, error) { - rows, err := d.q.GetUserSessions(dbCtx(), userID) +func (d *DB) GetUserSessions(ctx context.Context, userID int64) ([]Session, error) { + rows, err := d.q.GetUserSessions(ctx, userID) if err != nil { return nil, fmt.Errorf("GetUserSessions: %w", err) } @@ -127,8 +128,8 @@ func (d *DB) GetUserSessions(userID int64) ([]Session, error) { // AdminCreateChannel creates a channel with full field control including position. // No sqlc query covers this exact INSERT shape, so it stays on raw SQL. -func (d *DB) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) { - res, err := d.sqlDB.Exec( +func (d *DB) AdminCreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) { + res, err := d.sqlDB.ExecContext(ctx, `INSERT INTO channels (name, type, category, topic, position) VALUES (?, ?, ?, ?, ?)`, name, chanType, strToNullPtr(category), strToNullPtr(topic), position, @@ -140,8 +141,8 @@ func (d *DB) AdminCreateChannel(name, chanType, category, topic string, position } // AdminUpdateChannel updates all mutable channel fields. -func (d *DB) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error { - if err := d.q.AdminUpdateChannel(dbCtx(), dbgen.AdminUpdateChannelParams{ +func (d *DB) AdminUpdateChannel(ctx context.Context, id int64, name, topic string, slowMode, position int, archived bool) error { + if err := d.q.AdminUpdateChannel(ctx, dbgen.AdminUpdateChannelParams{ Name: name, Topic: strToNullPtr(topic), SlowMode: int64(slowMode), @@ -155,8 +156,8 @@ func (d *DB) AdminUpdateChannel(id int64, name, topic string, slowMode, position } // AdminDeleteChannel removes a channel by ID (cascades to messages, etc.). -func (d *DB) AdminDeleteChannel(id int64) error { - if err := d.q.DeleteChannel(dbCtx(), id); err != nil { +func (d *DB) AdminDeleteChannel(ctx context.Context, id int64) error { + if err := d.q.DeleteChannel(ctx, id); err != nil { return fmt.Errorf("AdminDeleteChannel: %w", err) } return nil @@ -165,8 +166,8 @@ func (d *DB) AdminDeleteChannel(id int64) error { // ─── Audit Log ──────────────────────────────────────────────────────────────── // LogAudit inserts an audit log entry. -func (d *DB) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error { - if err := d.q.LogAudit(dbCtx(), dbgen.LogAuditParams{ +func (d *DB) LogAudit(ctx context.Context, actorID int64, action, targetType string, targetID int64, detail string) error { + if err := d.q.LogAudit(ctx, dbgen.LogAuditParams{ ActorID: actorID, Action: action, TargetType: targetType, @@ -179,8 +180,8 @@ func (d *DB) LogAudit(actorID int64, action, targetType string, targetID int64, } // GetAuditLog returns audit log entries ordered newest-first with pagination. -func (d *DB) GetAuditLog(limit, offset int) ([]AuditEntry, error) { - rows, err := d.q.GetAuditLog(dbCtx(), dbgen.GetAuditLogParams{ +func (d *DB) GetAuditLog(ctx context.Context, limit, offset int) ([]AuditEntry, error) { + rows, err := d.q.GetAuditLog(ctx, dbgen.GetAuditLogParams{ Limit: int64(limit), Offset: int64(offset), }) @@ -207,8 +208,8 @@ func (d *DB) GetAuditLog(limit, offset int) ([]AuditEntry, error) { // GetSetting returns the value for the given settings key. // Returns an error (wrapping sql.ErrNoRows) when the key does not exist. -func (d *DB) GetSetting(key string) (string, error) { - value, err := d.q.GetSetting(dbCtx(), key) +func (d *DB) GetSetting(ctx context.Context, key string) (string, error) { + value, err := d.q.GetSetting(ctx, key) if errors.Is(err, sql.ErrNoRows) { return "", fmt.Errorf("GetSetting: key %q: %w", key, ErrNotFound) } @@ -219,8 +220,8 @@ func (d *DB) GetSetting(key string) (string, error) { } // SetSetting upserts a setting value for the given key. -func (d *DB) SetSetting(key, value string) error { - if err := d.q.SetSetting(dbCtx(), dbgen.SetSettingParams{ +func (d *DB) SetSetting(ctx context.Context, key, value string) error { + if err := d.q.SetSetting(ctx, dbgen.SetSettingParams{ Key: key, Value: value, }); err != nil { @@ -230,8 +231,8 @@ func (d *DB) SetSetting(key, value string) error { } // GetAllSettings returns all settings as a key→value map. -func (d *DB) GetAllSettings() (map[string]string, error) { - rows, err := d.q.GetAllSettings(dbCtx()) +func (d *DB) GetAllSettings(ctx context.Context) (map[string]string, error) { + rows, err := d.q.GetAllSettings(ctx) if err != nil { return nil, fmt.Errorf("GetAllSettings: %w", err) } @@ -244,8 +245,8 @@ func (d *DB) GetAllSettings() (map[string]string, error) { // CountUsersWithoutTOTP returns the number of non-banned users that do not // currently have a confirmed TOTP secret. -func (d *DB) CountUsersWithoutTOTP() (int, error) { - count, err := d.q.CountUsersWithoutTOTP(dbCtx()) +func (d *DB) CountUsersWithoutTOTP(ctx context.Context) (int, error) { + count, err := d.q.CountUsersWithoutTOTP(ctx) if err != nil { return 0, fmt.Errorf("CountUsersWithoutTOTP: %w", err) } @@ -266,13 +267,13 @@ func (d *DB) CountUsersWithoutTOTP() (int, error) { // // The caller in handleBackup constructs the path from a hardcoded directory // and a timestamp — no user input reaches this function. -func (d *DB) BackupTo(path string) error { - return d.BackupToSafe(path, filepath.Join("data", "backups")) +func (d *DB) BackupTo(ctx context.Context, path string) error { + return d.BackupToSafe(ctx, path, filepath.Join("data", "backups")) } // BackupToSafe is the internal implementation that accepts an explicit safe // root directory. Exported for testing with isolated directories. -func (d *DB) BackupToSafe(path, safeRoot string) error { +func (d *DB) BackupToSafe(ctx context.Context, path, safeRoot string) error { clean := filepath.Clean(path) absRoot, err := filepath.Abs(safeRoot) @@ -310,7 +311,7 @@ func (d *DB) BackupToSafe(path, safeRoot string) error { return fmt.Errorf("BackupToSafe: path contains forbidden sequence %q", "--") } - _, err = d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", absClean)) + _, err = d.sqlDB.ExecContext(ctx, fmt.Sprintf("VACUUM INTO '%s'", absClean)) if err != nil { return fmt.Errorf("BackupToSafe: %w", err) } diff --git a/Server/db/admin_queries_test.go b/Server/db/admin_queries_test.go index a0316da2..57920bee 100644 --- a/Server/db/admin_queries_test.go +++ b/Server/db/admin_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "fmt" "os" "path/filepath" @@ -87,7 +88,7 @@ func newAdminTestDB(t *testing.T) *db.DB { func TestGetServerStats_EmptyDB(t *testing.T) { database := newAdminTestDB(t) - stats, err := database.GetServerStats() + stats, err := database.GetServerStats(context.Background()) if err != nil { t.Fatalf("GetServerStats() error: %v", err) } @@ -114,17 +115,17 @@ func TestGetServerStats_EmptyDB(t *testing.T) { func TestGetServerStats_WithData(t *testing.T) { database := newAdminTestDB(t) - _, err := database.CreateUser("statuser", "hash", 4) + _, err := database.CreateUser(context.Background(), "statuser", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - _, err = database.CreateChannel("general", "text", "", "", 0) + _, err = database.CreateChannel(context.Background(), "general", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel error: %v", err) } - stats, err := database.GetServerStats() + stats, err := database.GetServerStats(context.Background()) if err != nil { t.Fatalf("GetServerStats() error: %v", err) } @@ -141,7 +142,7 @@ func TestGetServerStats_WithData(t *testing.T) { func TestListAllUsers_Empty(t *testing.T) { database := newAdminTestDB(t) - users, err := database.ListAllUsers(50, 0) + users, err := database.ListAllUsers(context.Background(), 50, 0) if err != nil { t.Fatalf("ListAllUsers() error: %v", err) } @@ -153,12 +154,12 @@ func TestListAllUsers_Empty(t *testing.T) { func TestListAllUsers_WithRoleName(t *testing.T) { database := newAdminTestDB(t) - _, err := database.CreateUser("alice", "hash", 4) + _, err := database.CreateUser(context.Background(), "alice", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - users, err := database.ListAllUsers(50, 0) + users, err := database.ListAllUsers(context.Background(), 50, 0) if err != nil { t.Fatalf("ListAllUsers() error: %v", err) } @@ -178,7 +179,7 @@ func TestListAllUsers_Pagination(t *testing.T) { database := newAdminTestDB(t) for i := range 5 { - _, err := database.CreateUser( + _, err := database.CreateUser(context.Background(), strings.Repeat("u", i+1), "hash", 4, @@ -188,7 +189,7 @@ func TestListAllUsers_Pagination(t *testing.T) { } } - page1, err := database.ListAllUsers(3, 0) + page1, err := database.ListAllUsers(context.Background(), 3, 0) if err != nil { t.Fatalf("ListAllUsers page1 error: %v", err) } @@ -196,7 +197,7 @@ func TestListAllUsers_Pagination(t *testing.T) { t.Errorf("page1 len = %d, want 3", len(page1)) } - page2, err := database.ListAllUsers(3, 3) + page2, err := database.ListAllUsers(context.Background(), 3, 3) if err != nil { t.Fatalf("ListAllUsers page2 error: %v", err) } @@ -207,9 +208,9 @@ func TestListAllUsers_Pagination(t *testing.T) { func TestListAllUsers_ZeroLimit(t *testing.T) { database := newAdminTestDB(t) - _, _ = database.CreateUser("zerotest", "hash", 4) + _, _ = database.CreateUser(context.Background(), "zerotest", "hash", 4) - users, err := database.ListAllUsers(0, 0) + users, err := database.ListAllUsers(context.Background(), 0, 0) if err != nil { t.Fatalf("ListAllUsers(0, 0) error: %v", err) } @@ -224,16 +225,16 @@ func TestListAllUsers_ZeroLimit(t *testing.T) { func TestUpdateUserRole(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("roleuser", "hash", 4) + uid, err := database.CreateUser(context.Background(), "roleuser", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - if err := database.UpdateUserRole(uid, 2); err != nil { + if err := database.UpdateUserRole(context.Background(), uid, 2); err != nil { t.Fatalf("UpdateUserRole() error: %v", err) } - user, err := database.GetUserByID(uid) + user, err := database.GetUserByID(context.Background(), uid) if err != nil { t.Fatalf("GetUserByID error: %v", err) } @@ -246,7 +247,7 @@ func TestUpdateUserRole_NonexistentUser(t *testing.T) { database := newAdminTestDB(t) // UPDATE with no matching rows is not an error - err := database.UpdateUserRole(99999, 2) + err := database.UpdateUserRole(context.Background(), 99999, 2) if err != nil { t.Errorf("UpdateUserRole() for nonexistent user returned unexpected error: %v", err) } @@ -257,15 +258,15 @@ func TestUpdateUserRole_NonexistentUser(t *testing.T) { func TestForceLogoutUser_DeletesSessions(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("logoutuser", "hash", 4) + uid, err := database.CreateUser(context.Background(), "logoutuser", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - _, _ = database.CreateSession(uid, "token1hash", "device1", "127.0.0.1") - _, _ = database.CreateSession(uid, "token2hash", "device2", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, "token1hash", "device1", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, "token2hash", "device2", "127.0.0.1") - sessions, err := database.GetUserSessions(uid) + sessions, err := database.GetUserSessions(context.Background(), uid) if err != nil { t.Fatalf("GetUserSessions error: %v", err) } @@ -273,11 +274,11 @@ func TestForceLogoutUser_DeletesSessions(t *testing.T) { t.Fatalf("expected 2 sessions before logout, got %d", len(sessions)) } - if err := database.ForceLogoutUser(uid); err != nil { + if err := database.ForceLogoutUser(context.Background(), uid); err != nil { t.Fatalf("ForceLogoutUser() error: %v", err) } - sessions, err = database.GetUserSessions(uid) + sessions, err = database.GetUserSessions(context.Background(), uid) if err != nil { t.Fatalf("GetUserSessions after logout error: %v", err) } @@ -289,12 +290,12 @@ func TestForceLogoutUser_DeletesSessions(t *testing.T) { func TestForceLogoutUser_NoSessions(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("nosessions", "hash", 4) + uid, err := database.CreateUser(context.Background(), "nosessions", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - if err := database.ForceLogoutUser(uid); err != nil { + if err := database.ForceLogoutUser(context.Background(), uid); err != nil { t.Errorf("ForceLogoutUser() on user with no sessions returned error: %v", err) } } @@ -304,12 +305,12 @@ func TestForceLogoutUser_NoSessions(t *testing.T) { func TestGetUserSessions_Empty(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("sessionuser", "hash", 4) + uid, err := database.CreateUser(context.Background(), "sessionuser", "hash", 4) if err != nil { t.Fatalf("CreateUser error: %v", err) } - sessions, err := database.GetUserSessions(uid) + sessions, err := database.GetUserSessions(context.Background(), uid) if err != nil { t.Fatalf("GetUserSessions() error: %v", err) } @@ -321,14 +322,14 @@ func TestGetUserSessions_Empty(t *testing.T) { func TestGetUserSessions_IsolatedByUser(t *testing.T) { database := newAdminTestDB(t) - uid1, _ := database.CreateUser("user1sess", "hash", 4) - uid2, _ := database.CreateUser("user2sess", "hash", 4) + uid1, _ := database.CreateUser(context.Background(), "user1sess", "hash", 4) + uid2, _ := database.CreateUser(context.Background(), "user2sess", "hash", 4) - _, _ = database.CreateSession(uid1, "u1t1", "web", "1.2.3.4") - _, _ = database.CreateSession(uid1, "u1t2", "mobile", "1.2.3.5") - _, _ = database.CreateSession(uid2, "u2t1", "web", "1.2.3.6") + _, _ = database.CreateSession(context.Background(), uid1, "u1t1", "web", "1.2.3.4") + _, _ = database.CreateSession(context.Background(), uid1, "u1t2", "mobile", "1.2.3.5") + _, _ = database.CreateSession(context.Background(), uid2, "u2t1", "web", "1.2.3.6") - sessions, err := database.GetUserSessions(uid1) + sessions, err := database.GetUserSessions(context.Background(), uid1) if err != nil { t.Fatalf("GetUserSessions() error: %v", err) } @@ -347,7 +348,7 @@ func TestGetUserSessions_IsolatedByUser(t *testing.T) { func TestAdminCreateChannel(t *testing.T) { database := newAdminTestDB(t) - id, err := database.AdminCreateChannel("announce", "text", "General", "Announcements", 1) + id, err := database.AdminCreateChannel(context.Background(), "announce", "text", "General", "Announcements", 1) if err != nil { t.Fatalf("AdminCreateChannel() error: %v", err) } @@ -355,7 +356,7 @@ func TestAdminCreateChannel(t *testing.T) { t.Errorf("AdminCreateChannel() id = %d, want > 0", id) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel() error: %v", err) } @@ -382,12 +383,12 @@ func TestAdminCreateChannel(t *testing.T) { func TestAdminCreateChannel_EmptyOptionals(t *testing.T) { database := newAdminTestDB(t) - id, err := database.AdminCreateChannel("simple", "voice", "", "", 0) + id, err := database.AdminCreateChannel(context.Background(), "simple", "voice", "", "", 0) if err != nil { t.Fatalf("AdminCreateChannel() error: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel() error: %v", err) } @@ -404,16 +405,16 @@ func TestAdminCreateChannel_EmptyOptionals(t *testing.T) { func TestAdminUpdateChannel(t *testing.T) { database := newAdminTestDB(t) - id, err := database.AdminCreateChannel("old-name", "text", "", "", 0) + id, err := database.AdminCreateChannel(context.Background(), "old-name", "text", "", "", 0) if err != nil { t.Fatalf("AdminCreateChannel() error: %v", err) } - if err := database.AdminUpdateChannel(id, "new-name", "new topic", 5, 2, true); err != nil { + if err := database.AdminUpdateChannel(context.Background(), id, "new-name", "new topic", 5, 2, true); err != nil { t.Fatalf("AdminUpdateChannel() error: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel() error: %v", err) } @@ -437,17 +438,17 @@ func TestAdminUpdateChannel(t *testing.T) { func TestAdminUpdateChannel_Unarchive(t *testing.T) { database := newAdminTestDB(t) - id, _ := database.AdminCreateChannel("arch-ch", "text", "", "", 0) - _ = database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, true) + id, _ := database.AdminCreateChannel(context.Background(), "arch-ch", "text", "", "", 0) + _ = database.AdminUpdateChannel(context.Background(), id, "arch-ch", "", 0, 0, true) - ch, _ := database.GetChannel(id) + ch, _ := database.GetChannel(context.Background(), id) if !ch.Archived { t.Fatal("channel should be archived") } // Unarchive - _ = database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, false) - ch, _ = database.GetChannel(id) + _ = database.AdminUpdateChannel(context.Background(), id, "arch-ch", "", 0, 0, false) + ch, _ = database.GetChannel(context.Background(), id) if ch.Archived { t.Error("Archived = true after unarchiving, want false") } @@ -458,16 +459,16 @@ func TestAdminUpdateChannel_Unarchive(t *testing.T) { func TestAdminDeleteChannel(t *testing.T) { database := newAdminTestDB(t) - id, err := database.AdminCreateChannel("to-delete", "text", "", "", 0) + id, err := database.AdminCreateChannel(context.Background(), "to-delete", "text", "", "", 0) if err != nil { t.Fatalf("AdminCreateChannel() error: %v", err) } - if err := database.AdminDeleteChannel(id); err != nil { + if err := database.AdminDeleteChannel(context.Background(), id); err != nil { t.Fatalf("AdminDeleteChannel() error: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel() after delete error: %v", err) } @@ -480,7 +481,7 @@ func TestAdminDeleteChannel_NonExistent(t *testing.T) { database := newAdminTestDB(t) // Deleting nonexistent channel should not error - if err := database.AdminDeleteChannel(99999); err != nil { + if err := database.AdminDeleteChannel(context.Background(), 99999); err != nil { t.Errorf("AdminDeleteChannel(nonexistent) error: %v", err) } } @@ -490,16 +491,16 @@ func TestAdminDeleteChannel_NonExistent(t *testing.T) { func TestLogAudit_AndRetrieve(t *testing.T) { database := newAdminTestDB(t) - uid, err := database.CreateUser("auditor", "hash", 1) + uid, err := database.CreateUser(context.Background(), "auditor", "hash", 1) if err != nil { t.Fatalf("CreateUser error: %v", err) } - if err := database.LogAudit(uid, "USER_BANNED", "user", 42, "banned for spam"); err != nil { + if err := database.LogAudit(context.Background(), uid, "USER_BANNED", "user", 42, "banned for spam"); err != nil { t.Fatalf("LogAudit() error: %v", err) } - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog() error: %v", err) } @@ -534,7 +535,7 @@ func TestLogAudit_AndRetrieve(t *testing.T) { func TestGetAuditLog_Empty(t *testing.T) { database := newAdminTestDB(t) - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog() error: %v", err) } @@ -546,12 +547,12 @@ func TestGetAuditLog_Empty(t *testing.T) { func TestGetAuditLog_Pagination(t *testing.T) { database := newAdminTestDB(t) - uid, _ := database.CreateUser("auditpager", "hash", 1) + uid, _ := database.CreateUser(context.Background(), "auditpager", "hash", 1) for i := range 5 { - _ = database.LogAudit(uid, "ACTION", "target", int64(i), "detail") + _ = database.LogAudit(context.Background(), uid, "ACTION", "target", int64(i), "detail") } - page1, err := database.GetAuditLog(3, 0) + page1, err := database.GetAuditLog(context.Background(), 3, 0) if err != nil { t.Fatalf("GetAuditLog page1 error: %v", err) } @@ -559,7 +560,7 @@ func TestGetAuditLog_Pagination(t *testing.T) { t.Errorf("page1 len = %d, want 3", len(page1)) } - page2, err := database.GetAuditLog(3, 3) + page2, err := database.GetAuditLog(context.Background(), 3, 3) if err != nil { t.Fatalf("GetAuditLog page2 error: %v", err) } @@ -571,11 +572,11 @@ func TestGetAuditLog_Pagination(t *testing.T) { func TestGetAuditLog_NewestFirst(t *testing.T) { database := newAdminTestDB(t) - uid, _ := database.CreateUser("auditorder", "hash", 1) - _ = database.LogAudit(uid, "FIRST", "", 0, "") - _ = database.LogAudit(uid, "SECOND", "", 0, "") + uid, _ := database.CreateUser(context.Background(), "auditorder", "hash", 1) + _ = database.LogAudit(context.Background(), uid, "FIRST", "", 0, "") + _ = database.LogAudit(context.Background(), uid, "SECOND", "", 0, "") - entries, err := database.GetAuditLog(10, 0) + entries, err := database.GetAuditLog(context.Background(), 10, 0) if err != nil { t.Fatalf("GetAuditLog() error: %v", err) } @@ -592,7 +593,7 @@ func TestGetAuditLog_NewestFirst(t *testing.T) { func TestGetSetting_Exists(t *testing.T) { database := newAdminTestDB(t) - val, err := database.GetSetting("server_name") + val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting() error: %v", err) } @@ -604,7 +605,7 @@ func TestGetSetting_Exists(t *testing.T) { func TestGetSetting_NotFound(t *testing.T) { database := newAdminTestDB(t) - _, err := database.GetSetting("nonexistent_key_xyz") + _, err := database.GetSetting(context.Background(), "nonexistent_key_xyz") if err == nil { t.Error("GetSetting() for nonexistent key should return error") } @@ -613,11 +614,11 @@ func TestGetSetting_NotFound(t *testing.T) { func TestSetSetting_NewKey(t *testing.T) { database := newAdminTestDB(t) - if err := database.SetSetting("custom_key", "custom_val"); err != nil { + if err := database.SetSetting(context.Background(), "custom_key", "custom_val"); err != nil { t.Fatalf("SetSetting() error: %v", err) } - val, err := database.GetSetting("custom_key") + val, err := database.GetSetting(context.Background(), "custom_key") if err != nil { t.Fatalf("GetSetting() after SetSetting error: %v", err) } @@ -629,11 +630,11 @@ func TestSetSetting_NewKey(t *testing.T) { func TestSetSetting_UpdateExisting(t *testing.T) { database := newAdminTestDB(t) - if err := database.SetSetting("server_name", "My Custom Server"); err != nil { + if err := database.SetSetting(context.Background(), "server_name", "My Custom Server"); err != nil { t.Fatalf("SetSetting() update error: %v", err) } - val, err := database.GetSetting("server_name") + val, err := database.GetSetting(context.Background(), "server_name") if err != nil { t.Fatalf("GetSetting() error: %v", err) } @@ -645,7 +646,7 @@ func TestSetSetting_UpdateExisting(t *testing.T) { func TestGetAllSettings_ReturnsMap(t *testing.T) { database := newAdminTestDB(t) - settings, err := database.GetAllSettings() + settings, err := database.GetAllSettings(context.Background()) if err != nil { t.Fatalf("GetAllSettings() error: %v", err) } @@ -660,9 +661,9 @@ func TestGetAllSettings_ReturnsMap(t *testing.T) { func TestGetAllSettings_AfterClearing(t *testing.T) { database := newAdminTestDB(t) - _, _ = database.Exec("DELETE FROM settings") + _, _ = database.ExecContext(context.Background(), "DELETE FROM settings") - settings, err := database.GetAllSettings() + settings, err := database.GetAllSettings(context.Background()) if err != nil { t.Fatalf("GetAllSettings() after clearing error: %v", err) } @@ -693,7 +694,7 @@ func TestBackupToSafe_AdminQueries(t *testing.T) { backupDir := filepath.Join(tmpDir, "backups") _ = os.MkdirAll(backupDir, 0o755) backupPath := filepath.Join(backupDir, "backup.db") - if err := database.BackupToSafe(backupPath, backupDir); err != nil { + if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil { t.Fatalf("BackupToSafe() error: %v", err) } @@ -725,7 +726,7 @@ func TestBackupToSafe_CreatesDirectoryFile(t *testing.T) { _ = os.MkdirAll(backupDir, 0o755) backupPath := filepath.Join(backupDir, "chatserver_20260314_120000.db") - if err := database.BackupToSafe(backupPath, backupDir); err != nil { + if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil { t.Fatalf("BackupToSafe() error: %v", err) } @@ -739,7 +740,7 @@ func TestBackupToSafe_CreatesDirectoryFile(t *testing.T) { func TestUserCount_Empty(t *testing.T) { database := newAdminTestDB(t) - count, err := database.UserCount() + count, err := database.UserCount(context.Background()) if err != nil { t.Fatalf("UserCount() error: %v", err) } @@ -752,7 +753,7 @@ func TestUserCount_WithUsers(t *testing.T) { database := newAdminTestDB(t) for i := range 3 { - _, err := database.CreateUser( + _, err := database.CreateUser(context.Background(), fmt.Sprintf("countuser%d", i), "hash", 4, @@ -762,7 +763,7 @@ func TestUserCount_WithUsers(t *testing.T) { } } - count, err := database.UserCount() + count, err := database.UserCount(context.Background()) if err != nil { t.Fatalf("UserCount() error: %v", err) } @@ -793,7 +794,7 @@ func TestBackupToSafe_DirectCall(t *testing.T) { backupDir := filepath.Join(tmpDir, "backups") _ = os.MkdirAll(backupDir, 0o755) backupPath := filepath.Join(backupDir, "backup_direct.db") - if err := database.BackupToSafe(backupPath, backupDir); err != nil { + if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil { t.Fatalf("BackupToSafe() error: %v", err) } @@ -825,7 +826,7 @@ func TestBackupToSafe_RejectsTraversal(t *testing.T) { _ = os.MkdirAll(safeRoot, 0o755) unsafePath := filepath.Join(tmpDir, "outside", "evil.db") - err = database.BackupToSafe(unsafePath, safeRoot) + err = database.BackupToSafe(context.Background(), unsafePath, safeRoot) if err == nil { t.Error("BackupToSafe should reject path outside safe root") } diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index c6cdcafd..14a9630f 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -33,8 +34,8 @@ type AttachmentAccess struct { // CreateAttachment inserts a new attachment record (initially unlinked to any message). // uploaderID records who uploaded the file for ownership checks on unlinked files. // width and height are optional image dimensions (pass nil for non-image files). -func (d *DB) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { - if err := d.q.CreateAttachment(dbCtx(), dbgen.CreateAttachmentParams{ +func (d *DB) CreateAttachment(ctx context.Context, id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { + if err := d.q.CreateAttachment(ctx, dbgen.CreateAttachmentParams{ ID: id, UploaderID: &uploaderID, Filename: filename, @@ -50,8 +51,8 @@ func (d *DB) CreateAttachment(id string, uploaderID int64, filename, storedAs, m } // GetAttachmentByID returns the attachment with the given ID, or nil if not found. -func (d *DB) GetAttachmentByID(id string) (*Attachment, error) { - r, err := d.q.GetAttachmentByID(dbCtx(), id) +func (d *DB) GetAttachmentByID(ctx context.Context, id string) (*Attachment, error) { + r, err := d.q.GetAttachmentByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -74,8 +75,8 @@ func (d *DB) GetAttachmentByID(id string) (*Attachment, error) { // (channel ID and type) for access-control checks. Returns nil if the // attachment does not exist. ChannelID/ChannelType are nil/empty when the // attachment is unlinked or its message/channel was deleted. -func (d *DB) GetAttachmentWithChannel(id string) (*AttachmentAccess, error) { - r, err := d.q.GetAttachmentWithChannel(dbCtx(), id) +func (d *DB) GetAttachmentWithChannel(ctx context.Context, id string) (*AttachmentAccess, error) { + r, err := d.q.GetAttachmentWithChannel(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -107,7 +108,7 @@ func (d *DB) GetAttachmentWithChannel(id string) (*AttachmentAccess, error) { // is the atomic attachment-IDOR guard for message sends: ownership is // enforced in the same statement that links, so there is no check-then-link // race. Returns the number of rows updated. -func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) { +func (d *DB) LinkAttachmentsToMessage(ctx context.Context, messageID, uploaderID int64, attachmentIDs []string) (int64, error) { if len(attachmentIDs) == 0 { return 0, nil } @@ -127,7 +128,7 @@ func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs AND (uploader_id = ? OR uploader_id IS NULL)`, strings.Join(placeholders, ","), ) - res, err := d.sqlDB.Exec(query, args...) + res, err := d.sqlDB.ExecContext(ctx, query, args...) if err != nil { return 0, fmt.Errorf("LinkAttachmentsToMessage: %w", err) } @@ -135,7 +136,7 @@ func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs } // GetAttachmentsByMessageIDs returns attachments grouped by message ID. -func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentInfo, error) { +func (d *DB) GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]AttachmentInfo, error) { if len(msgIDs) == 0 { return map[int64][]AttachmentInfo{}, nil } @@ -152,7 +153,7 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI FROM attachments WHERE message_id IN (%s)`, strings.Join(placeholders, ","), ) - rows, err := d.sqlDB.Query(query, args...) + rows, err := d.sqlDB.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("GetAttachmentsByMessageIDs: %w", err) } @@ -184,8 +185,8 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI // BUG-132: Uses DELETE ... RETURNING to make select+delete atomic, // preventing a race where an attachment linked between SELECT and DELETE // would have its file deleted while the DB row survives. -func (d *DB) DeleteOrphanedAttachments(cutoff string) ([]string, error) { - files, err := d.q.DeleteOrphanedAttachments(dbCtx(), cutoff) +func (d *DB) DeleteOrphanedAttachments(ctx context.Context, cutoff string) ([]string, error) { + files, err := d.q.DeleteOrphanedAttachments(ctx, cutoff) if err != nil { return nil, fmt.Errorf("DeleteOrphanedAttachments: %w", err) } diff --git a/Server/db/attachment_queries_test.go b/Server/db/attachment_queries_test.go index 4048b63c..b1a18e47 100644 --- a/Server/db/attachment_queries_test.go +++ b/Server/db/attachment_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" ) @@ -9,7 +10,7 @@ import ( func TestGetAttachmentByID_NotFound(t *testing.T) { database := openMigratedMemory(t) - att, err := database.GetAttachmentByID("nonexistent-id") + att, err := database.GetAttachmentByID(context.Background(), "nonexistent-id") if err != nil { t.Errorf("GetAttachmentByID for nonexistent ID should return nil error, got %v", err) } @@ -22,7 +23,7 @@ func TestGetAttachmentByID_Found(t *testing.T) { database := openMigratedMemory(t) // Insert an attachment directly. - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES (?, ?, ?, ?, ?)`, "att-001", "photo.png", "stored-photo.png", "image/png", 12345, @@ -31,7 +32,7 @@ func TestGetAttachmentByID_Found(t *testing.T) { t.Fatalf("inserting attachment: %v", err) } - att, err := database.GetAttachmentByID("att-001") + att, err := database.GetAttachmentByID(context.Background(), "att-001") if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -57,7 +58,7 @@ func TestGetAttachmentByID_Found(t *testing.T) { func TestLinkAttachmentsToMessage_Empty(t *testing.T) { database := openMigratedMemory(t) - n, err := database.LinkAttachmentsToMessage(1, 1, nil) + n, err := database.LinkAttachmentsToMessage(context.Background(), 1, 1, nil) if err != nil { t.Fatalf("LinkAttachmentsToMessage(nil): %v", err) } @@ -70,11 +71,11 @@ func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "linkuser") chID := seedChannel(t, database, "linkchan") - msgID, _ := database.CreateMessage(chID, userID, "with attachment", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "with attachment", nil) // Insert two unlinked attachments. for _, id := range []string{"att-a", "att-b"} { - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES (?, ?, ?, ?, ?)`, id, "file.txt", "stored.txt", "text/plain", 100, @@ -84,7 +85,7 @@ func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { } } - n, err := database.LinkAttachmentsToMessage(msgID, userID, []string{"att-a", "att-b"}) + n, err := database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"att-a", "att-b"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) } @@ -93,7 +94,7 @@ func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { } // Verify linkage. - att, _ := database.GetAttachmentByID("att-a") + att, _ := database.GetAttachmentByID(context.Background(), "att-a") if att.MessageID == nil || *att.MessageID != msgID { t.Errorf("att-a MessageID = %v, want %d", att.MessageID, msgID) } @@ -103,17 +104,17 @@ func TestLinkAttachmentsToMessage_SkipsAlreadyLinked(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "linkuser2") chID := seedChannel(t, database, "linkchan2") - msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) - msg2, _ := database.CreateMessage(chID, userID, "msg2", nil) + msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil) + msg2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil) - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size, message_id) VALUES (?, ?, ?, ?, ?, ?)`, "att-linked", "file.txt", "stored.txt", "text/plain", 100, msg1, ) // Try to re-link to a different message — should skip (WHERE message_id IS NULL). - n, err := database.LinkAttachmentsToMessage(msg2, userID, []string{"att-linked"}) + n, err := database.LinkAttachmentsToMessage(context.Background(), msg2, userID, []string{"att-linked"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) } @@ -131,23 +132,23 @@ func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) { owner := seedUser(t, database, "att-owner") other := seedUser(t, database, "att-other") chID := seedChannel(t, database, "att-owner-ch") - msgID, _ := database.CreateMessage(chID, owner, "attachment carrier", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, owner, "attachment carrier", nil) - if err := database.CreateAttachment("att-owned", owner, "o.txt", "s-o.txt", "text/plain", 1, nil, nil); err != nil { + if err := database.CreateAttachment(context.Background(), "att-owned", owner, "o.txt", "s-o.txt", "text/plain", 1, nil, nil); err != nil { t.Fatalf("CreateAttachment att-owned: %v", err) } - if err := database.CreateAttachment("att-foreign", other, "f.txt", "s-f.txt", "text/plain", 1, nil, nil); err != nil { + if err := database.CreateAttachment(context.Background(), "att-foreign", other, "f.txt", "s-f.txt", "text/plain", 1, nil, nil); err != nil { t.Fatalf("CreateAttachment att-foreign: %v", err) } // Legacy row from before uploader tracking: uploader_id IS NULL. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES ('att-legacy', 'l.txt', 's-l.txt', 'text/plain', 1)`, ); err != nil { t.Fatalf("inserting legacy attachment: %v", err) } - n, err := database.LinkAttachmentsToMessage(msgID, owner, + n, err := database.LinkAttachmentsToMessage(context.Background(), msgID, owner, []string{"att-owned", "att-foreign", "att-legacy", "att-missing"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) @@ -155,13 +156,13 @@ func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) { if n != 2 { t.Errorf("expected 2 linked (owned + legacy), got %d", n) } - if att, _ := database.GetAttachmentByID("att-owned"); att.MessageID == nil || *att.MessageID != msgID { + if att, _ := database.GetAttachmentByID(context.Background(), "att-owned"); att.MessageID == nil || *att.MessageID != msgID { t.Error("owner's unlinked attachment should link") } - if att, _ := database.GetAttachmentByID("att-foreign"); att.MessageID != nil { + if att, _ := database.GetAttachmentByID(context.Background(), "att-foreign"); att.MessageID != nil { t.Error("another user's attachment must never link (IDOR guard)") } - if att, _ := database.GetAttachmentByID("att-legacy"); att.MessageID == nil { + if att, _ := database.GetAttachmentByID(context.Background(), "att-legacy"); att.MessageID == nil { t.Error("legacy NULL-uploader attachment should be claimable") } } @@ -171,7 +172,7 @@ func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) { func TestGetAttachmentsByMessageIDs_Empty(t *testing.T) { database := openMigratedMemory(t) - result, err := database.GetAttachmentsByMessageIDs(nil) + result, err := database.GetAttachmentsByMessageIDs(context.Background(), nil) if err != nil { t.Fatalf("GetAttachmentsByMessageIDs(nil): %v", err) } @@ -184,8 +185,8 @@ func TestGetAttachmentsByMessageIDs_GroupsByMessage(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "attuser") chID := seedChannel(t, database, "attchan") - msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) - msg2, _ := database.CreateMessage(chID, userID, "msg2", nil) + msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil) + msg2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil) // Two attachments on msg1, one on msg2. for _, row := range []struct { @@ -196,7 +197,7 @@ func TestGetAttachmentsByMessageIDs_GroupsByMessage(t *testing.T) { {"att-1b", msg1}, {"att-2a", msg2}, } { - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO attachments (id, filename, stored_as, mime_type, size, message_id) VALUES (?, ?, ?, ?, ?, ?)`, row.id, "f.txt", "s.txt", "text/plain", 50, row.msgID, @@ -206,7 +207,7 @@ func TestGetAttachmentsByMessageIDs_GroupsByMessage(t *testing.T) { } } - result, err := database.GetAttachmentsByMessageIDs([]int64{msg1, msg2}) + result, err := database.GetAttachmentsByMessageIDs(context.Background(), []int64{msg1, msg2}) if err != nil { t.Fatalf("GetAttachmentsByMessageIDs: %v", err) } diff --git a/Server/db/audit.go b/Server/db/audit.go index 025d287d..e12f348d 100644 --- a/Server/db/audit.go +++ b/Server/db/audit.go @@ -1,13 +1,16 @@ package db -import "log/slog" +import ( + "context" + "log/slog" +) // Auditor is the minimal audit-write surface WriteAudit needs. *DB satisfies // it directly, and the service layer's Store interface does too, so every // caller — api, admin, ws, service — can route its audit writes through this // one helper regardless of whether it holds a *DB or a narrower interface. type Auditor interface { - LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error + LogAudit(ctx context.Context, actorID int64, action, targetType string, targetID int64, detail string) error } // WriteAudit records an audit entry best-effort. @@ -19,8 +22,8 @@ type Auditor interface { // gap is visible in the logs. The detail string is intentionally not logged; // it can carry request-specific or sensitive text and the structured fields // already identify what was attempted. -func WriteAudit(a Auditor, actorID int64, action, targetType string, targetID int64, detail string) { - if err := a.LogAudit(actorID, action, targetType, targetID, detail); err != nil { +func WriteAudit(ctx context.Context, a Auditor, actorID int64, action, targetType string, targetID int64, detail string) { + if err := a.LogAudit(ctx, actorID, action, targetType, targetID, detail); err != nil { slog.Error("audit log write failed", "action", action, "actor_id", actorID, diff --git a/Server/db/audit_test.go b/Server/db/audit_test.go index d638a8f7..ec2de03e 100644 --- a/Server/db/audit_test.go +++ b/Server/db/audit_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "errors" "log/slog" "strings" @@ -16,7 +17,7 @@ type fakeAuditor struct { called bool } -func (f *fakeAuditor) LogAudit(_ int64, _, _ string, _ int64, _ string) error { +func (f *fakeAuditor) LogAudit(_ context.Context, _ int64, _, _ string, _ int64, _ string) error { f.called = true return f.err } @@ -39,7 +40,7 @@ func TestWriteAudit_LogsFailureButDoesNotPropagate(t *testing.T) { // WriteAudit returns nothing, so "never propagated" is structural — the // call simply must not panic and must record the failure. out := captureLogs(t, func() { - db.WriteAudit(a, 7, "user_ban", "user", 42, "spam") + db.WriteAudit(context.Background(), a, 7, "user_ban", "user", 42, "spam") }) if !a.called { @@ -67,7 +68,7 @@ func TestWriteAudit_SuccessLogsNothing(t *testing.T) { a := &fakeAuditor{err: nil} out := captureLogs(t, func() { - db.WriteAudit(a, 1, "user_login", "user", 1, "") + db.WriteAudit(context.Background(), a, 1, "user_login", "user", 1, "") }) if !a.called { diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 66b84aa9..9062faf4 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "crypto/rand" "database/sql" "encoding/hex" @@ -14,8 +15,8 @@ import ( // ─── User Operations ────────────────────────────────────────────────────────── // CreateUser inserts a new user record and returns the assigned ID. -func (d *DB) CreateUser(username, passwordHash string, roleID int) (int64, error) { - res, err := d.sqlDB.Exec( +func (d *DB) CreateUser(ctx context.Context, username, passwordHash string, roleID int) (int64, error) { + res, err := d.sqlDB.ExecContext(ctx, `INSERT INTO users (username, password, role_id) VALUES (?, ?, ?)`, username, passwordHash, roleID, ) @@ -28,8 +29,8 @@ func (d *DB) CreateUser(username, passwordHash string, roleID int) (int64, error // CreateOwnerIfEmpty atomically checks that no users exist and inserts the // first owner in a single transaction. Returns ErrConflict if any user already // exists, closing the TOCTOU race in the setup endpoint (BUG-119). -func (d *DB) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) { - tx, err := d.sqlDB.Begin() +func (d *DB) CreateOwnerIfEmpty(ctx context.Context, username, passwordHash string, roleID int) (int64, error) { + tx, err := d.sqlDB.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("CreateOwnerIfEmpty begin: %w", err) } @@ -70,8 +71,8 @@ func (d *DB) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int6 // CreateUserWithInvite atomically consumes an invite and creates the user in // the same transaction so a failed registration does not burn the invite. -func (d *DB) CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) { - tx, err := d.sqlDB.Begin() +func (d *DB) CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode string) (int64, error) { + tx, err := d.sqlDB.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("CreateUserWithInvite begin: %w", err) } @@ -120,8 +121,8 @@ func (d *DB) CreateUserWithInvite(username, passwordHash string, roleID int, inv // GetUserByUsername returns the user with the given username (case-insensitive), // or nil if not found. -func (d *DB) GetUserByUsername(username string) (*User, error) { - u, err := d.q.GetUserByUsername(dbCtx(), username) +func (d *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) { + u, err := d.q.GetUserByUsername(ctx, username) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -132,8 +133,8 @@ func (d *DB) GetUserByUsername(username string) (*User, error) { } // GetUserByID returns the user with the given ID, or nil if not found. -func (d *DB) GetUserByID(id int64) (*User, error) { - u, err := d.q.GetUserByID(dbCtx(), id) +func (d *DB) GetUserByID(ctx context.Context, id int64) (*User, error) { + u, err := d.q.GetUserByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -144,8 +145,8 @@ func (d *DB) GetUserByID(id int64) (*User, error) { } // UpdateUserStatus sets the status column for the given user ID. -func (d *DB) UpdateUserStatus(id int64, status string) error { - if err := d.q.UpdateUserStatus(dbCtx(), dbgen.UpdateUserStatusParams{ +func (d *DB) UpdateUserStatus(ctx context.Context, id int64, status string) error { + if err := d.q.UpdateUserStatus(ctx, dbgen.UpdateUserStatusParams{ Status: status, ID: id, }); err != nil { @@ -155,8 +156,8 @@ func (d *DB) UpdateUserStatus(id int64, status string) error { } // UpdateUserTOTPSecret sets or clears the TOTP secret for a user. -func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error { - if err := d.q.UpdateUserTOTPSecret(dbCtx(), dbgen.UpdateUserTOTPSecretParams{ +func (d *DB) UpdateUserTOTPSecret(ctx context.Context, id int64, secret *string) error { + if err := d.q.UpdateUserTOTPSecret(ctx, dbgen.UpdateUserTOTPSecretParams{ TotpSecret: secret, ID: id, }); err != nil { @@ -167,8 +168,8 @@ func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error { // ResetAllUserStatuses sets all users to "offline". Called on server startup // to clear stale statuses from a previous run or crash. -func (d *DB) ResetAllUserStatuses() error { - if err := d.q.ResetAllUserStatuses(dbCtx()); err != nil { +func (d *DB) ResetAllUserStatuses(ctx context.Context) error { + if err := d.q.ResetAllUserStatuses(ctx); err != nil { return fmt.Errorf("ResetAllUserStatuses: %w", err) } return nil @@ -176,14 +177,14 @@ func (d *DB) ResetAllUserStatuses() error { // BanUser marks a user as banned with an optional expiry. Pass nil for a // permanent ban. -func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { +func (d *DB) BanUser(ctx context.Context, id int64, reason string, expires *time.Time) error { var expiresStr *string if expires != nil { s := expires.UTC().Format("2006-01-02T15:04:05Z") expiresStr = &s } reasonCopy := reason - if err := d.q.BanUser(dbCtx(), dbgen.BanUserParams{ + if err := d.q.BanUser(ctx, dbgen.BanUserParams{ BanReason: &reasonCopy, BanExpires: expiresStr, ID: id, @@ -194,8 +195,8 @@ func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { } // UnbanUser removes the ban from a user. -func (d *DB) UnbanUser(id int64) error { - if err := d.q.UnbanUser(dbCtx(), id); err != nil { +func (d *DB) UnbanUser(ctx context.Context, id int64) error { + if err := d.q.UnbanUser(ctx, id); err != nil { return fmt.Errorf("UnbanUser: %w", err) } return nil @@ -212,16 +213,16 @@ const maxSessionsPerUser = 25 // tokenHash must already be hashed (never store plaintext tokens). // H-6: Enforces a per-user session cap by evicting the oldest session when // the limit is reached. -func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { +func (d *DB) CreateSession(ctx context.Context, userID int64, tokenHash, device, ip string) (int64, error) { // Evict oldest sessions if at or above the cap. - _ = d.q.EvictOldestSessions(dbCtx(), dbgen.EvictOldestSessionsParams{ + _ = d.q.EvictOldestSessions(ctx, dbgen.EvictOldestSessionsParams{ UserID: userID, Offset: maxSessionsPerUser - 1, }) expiresAt := time.Now().Add(sessionTTL).UTC().Format("2006-01-02T15:04:05Z") deviceCopy, ipCopy := device, ip - res, err := d.q.InsertSession(dbCtx(), dbgen.InsertSessionParams{ + res, err := d.q.InsertSession(ctx, dbgen.InsertSessionParams{ UserID: userID, Token: tokenHash, Device: &deviceCopy, @@ -236,8 +237,8 @@ func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, e // GetSessionByTokenHash retrieves a session by its hashed token, or nil if // not found. -func (d *DB) GetSessionByTokenHash(tokenHash string) (*Session, error) { - s, err := d.q.GetSessionByTokenHash(dbCtx(), tokenHash) +func (d *DB) GetSessionByTokenHash(ctx context.Context, tokenHash string) (*Session, error) { + s, err := d.q.GetSessionByTokenHash(ctx, tokenHash) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -259,8 +260,8 @@ type SessionWithBanStatus struct { // GetSessionWithBanStatus returns the session joined with the user's ban // status in a single query. Returns nil, nil when not found. -func (d *DB) GetSessionWithBanStatus(tokenHash string) (*SessionWithBanStatus, error) { - row, err := d.q.GetSessionWithBanStatus(dbCtx(), tokenHash) +func (d *DB) GetSessionWithBanStatus(ctx context.Context, tokenHash string) (*SessionWithBanStatus, error) { + row, err := d.q.GetSessionWithBanStatus(ctx, tokenHash) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -285,8 +286,8 @@ func (d *DB) GetSessionWithBanStatus(tokenHash string) (*SessionWithBanStatus, e } // DeleteSession removes the session with the given token hash. -func (d *DB) DeleteSession(tokenHash string) error { - if err := d.q.DeleteSessionByToken(dbCtx(), tokenHash); err != nil { +func (d *DB) DeleteSession(ctx context.Context, tokenHash string) error { + if err := d.q.DeleteSessionByToken(ctx, tokenHash); err != nil { return fmt.Errorf("DeleteSession: %w", err) } return nil @@ -295,8 +296,8 @@ func (d *DB) DeleteSession(tokenHash string) error { // DeleteOtherSessions removes all sessions for the given user except the one // with keepSessionID. Used after password change or 2FA state change to // invalidate all other sessions (BUG-108). -func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { - result, err := d.q.DeleteOtherSessions(dbCtx(), dbgen.DeleteOtherSessionsParams{ +func (d *DB) DeleteOtherSessions(ctx context.Context, userID, keepSessionID int64) (int64, error) { + result, err := d.q.DeleteOtherSessions(ctx, dbgen.DeleteOtherSessionsParams{ UserID: userID, ID: keepSessionID, }) @@ -309,16 +310,16 @@ func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { // DeleteExpiredSessions removes all sessions whose expires_at is in the past. // Compares using strftime to handle both ISO-8601 and SQLite datetime formats. -func (d *DB) DeleteExpiredSessions() error { - if err := d.q.DeleteExpiredSessions(dbCtx()); err != nil { +func (d *DB) DeleteExpiredSessions(ctx context.Context) error { + if err := d.q.DeleteExpiredSessions(ctx); err != nil { return fmt.Errorf("DeleteExpiredSessions: %w", err) } return nil } // TouchSession updates last_used for the session with the given token hash. -func (d *DB) TouchSession(tokenHash string) error { - if err := d.q.TouchSession(dbCtx(), tokenHash); err != nil { +func (d *DB) TouchSession(ctx context.Context, tokenHash string) error { + if err := d.q.TouchSession(ctx, tokenHash); err != nil { return fmt.Errorf("TouchSession: %w", err) } return nil @@ -328,7 +329,7 @@ func (d *DB) TouchSession(tokenHash string) error { // CreateInvite generates a random invite code, persists it, and returns the // code. maxUses=0 means unlimited. expiresAt=nil means never expires. -func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { +func (d *DB) CreateInvite(ctx context.Context, createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { code, err := generateInviteCode() if err != nil { return "", fmt.Errorf("CreateInvite generate code: %w", err) @@ -344,7 +345,7 @@ func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (s expiresStr = &s } - if err := d.q.CreateInvite(dbCtx(), dbgen.CreateInviteParams{ + if err := d.q.CreateInvite(ctx, dbgen.CreateInviteParams{ Code: code, CreatedBy: createdBy, MaxUses: ptrItoI64(maxUsesVal), @@ -356,8 +357,8 @@ func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (s } // GetInvite returns the invite for the given code, or nil if not found. -func (d *DB) GetInvite(code string) (*Invite, error) { - r, err := d.q.GetInvite(dbCtx(), code) +func (d *DB) GetInvite(ctx context.Context, code string) (*Invite, error) { + r, err := d.q.GetInvite(ctx, code) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -388,8 +389,8 @@ func (d *DB) GetInvite(code string) (*Invite, error) { // // If zero rows are affected the invite is missing, revoked, expired, or // exhausted — an error is returned in all such cases. -func (d *DB) UseInviteAtomic(code string) error { - result, err := d.q.UseInviteAtomic(dbCtx(), code) +func (d *DB) UseInviteAtomic(ctx context.Context, code string) error { + result, err := d.q.UseInviteAtomic(ctx, code) if err != nil { return fmt.Errorf("UseInviteAtomic: %w", err) } @@ -404,8 +405,8 @@ func (d *DB) UseInviteAtomic(code string) error { } // RevokeInvite marks an invite as revoked. -func (d *DB) RevokeInvite(code string) error { - if err := d.q.RevokeInvite(dbCtx(), code); err != nil { +func (d *DB) RevokeInvite(ctx context.Context, code string) error { + if err := d.q.RevokeInvite(ctx, code); err != nil { return fmt.Errorf("RevokeInvite: %w", err) } return nil @@ -424,8 +425,8 @@ type MemberSummary struct { // ListMembers returns non-banned users as lightweight summaries. // M-12: Limited to 1000 rows to prevent unbounded result sets on large servers. -func (d *DB) ListMembers() ([]MemberSummary, error) { - rows, err := d.q.ListMembers(dbCtx()) +func (d *DB) ListMembers(ctx context.Context) ([]MemberSummary, error) { + rows, err := d.q.ListMembers(ctx) if err != nil { return nil, fmt.Errorf("ListMembers: %w", err) } diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go index 11f31d1b..44f20ce7 100644 --- a/Server/db/auth_queries_test.go +++ b/Server/db/auth_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "testing/fstest" "time" @@ -93,7 +94,7 @@ CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); func TestCreateUser_Success(t *testing.T) { database := newTestDB(t) - id, err := database.CreateUser("alice", "hash123", 4) + id, err := database.CreateUser(context.Background(), "alice", "hash123", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -104,10 +105,10 @@ func TestCreateUser_Success(t *testing.T) { func TestCreateUser_DuplicateUsername(t *testing.T) { database := newTestDB(t) - if _, err := database.CreateUser("bob", "hash1", 4); err != nil { + if _, err := database.CreateUser(context.Background(), "bob", "hash1", 4); err != nil { t.Fatalf("first CreateUser: %v", err) } - _, err := database.CreateUser("bob", "hash2", 4) + _, err := database.CreateUser(context.Background(), "bob", "hash2", 4) if err == nil { t.Error("CreateUser() with duplicate username returned nil error, want error") } @@ -115,10 +116,10 @@ func TestCreateUser_DuplicateUsername(t *testing.T) { func TestCreateUser_CaseInsensitiveDuplicate(t *testing.T) { database := newTestDB(t) - if _, err := database.CreateUser("Charlie", "hash1", 4); err != nil { + if _, err := database.CreateUser(context.Background(), "Charlie", "hash1", 4); err != nil { t.Fatalf("first CreateUser: %v", err) } - _, err := database.CreateUser("charlie", "hash2", 4) + _, err := database.CreateUser(context.Background(), "charlie", "hash2", 4) if err == nil { t.Error("CreateUser() with case-insensitive duplicate returned nil error, want error") } @@ -126,9 +127,9 @@ func TestCreateUser_CaseInsensitiveDuplicate(t *testing.T) { func TestGetUserByUsername_Found(t *testing.T) { database := newTestDB(t) - _, _ = database.CreateUser("dave", "hashDave", 4) + _, _ = database.CreateUser(context.Background(), "dave", "hashDave", 4) - user, err := database.GetUserByUsername("dave") + user, err := database.GetUserByUsername(context.Background(), "dave") if err != nil { t.Fatalf("GetUserByUsername: %v", err) } @@ -142,9 +143,9 @@ func TestGetUserByUsername_Found(t *testing.T) { func TestGetUserByUsername_CaseInsensitive(t *testing.T) { database := newTestDB(t) - _, _ = database.CreateUser("Eve", "hashEve", 4) + _, _ = database.CreateUser(context.Background(), "Eve", "hashEve", 4) - user, err := database.GetUserByUsername("EVE") + user, err := database.GetUserByUsername(context.Background(), "EVE") if err != nil { t.Fatalf("GetUserByUsername case-insensitive: %v", err) } @@ -155,7 +156,7 @@ func TestGetUserByUsername_CaseInsensitive(t *testing.T) { func TestGetUserByUsername_NotFound(t *testing.T) { database := newTestDB(t) - user, err := database.GetUserByUsername("nobody") + user, err := database.GetUserByUsername(context.Background(), "nobody") if err != nil { t.Fatalf("GetUserByUsername(not found): %v", err) } @@ -166,9 +167,9 @@ func TestGetUserByUsername_NotFound(t *testing.T) { func TestGetUserByID_Found(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("frank", "hashFrank", 4) + id, _ := database.CreateUser(context.Background(), "frank", "hashFrank", 4) - user, err := database.GetUserByID(id) + user, err := database.GetUserByID(context.Background(), id) if err != nil { t.Fatalf("GetUserByID: %v", err) } @@ -179,7 +180,7 @@ func TestGetUserByID_Found(t *testing.T) { func TestGetUserByID_NotFound(t *testing.T) { database := newTestDB(t) - user, err := database.GetUserByID(999) + user, err := database.GetUserByID(context.Background(), 999) if err != nil { t.Fatalf("GetUserByID(not found): %v", err) } @@ -190,12 +191,12 @@ func TestGetUserByID_NotFound(t *testing.T) { func TestUpdateUserStatus(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("grace", "hash", 4) + id, _ := database.CreateUser(context.Background(), "grace", "hash", 4) - if err := database.UpdateUserStatus(id, "online"); err != nil { + if err := database.UpdateUserStatus(context.Background(), id, "online"); err != nil { t.Fatalf("UpdateUserStatus: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if user.Status != "online" { t.Errorf("Status = %q, want %q", user.Status, "online") } @@ -203,12 +204,12 @@ func TestUpdateUserStatus(t *testing.T) { func TestBanUser_Permanent(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("hank", "hash", 4) + id, _ := database.CreateUser(context.Background(), "hank", "hash", 4) - if err := database.BanUser(id, "spam", nil); err != nil { + if err := database.BanUser(context.Background(), id, "spam", nil); err != nil { t.Fatalf("BanUser: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if !user.Banned { t.Error("Banned = false after BanUser, want true") } @@ -219,13 +220,13 @@ func TestBanUser_Permanent(t *testing.T) { func TestBanUser_Temporary(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("ivan", "hash", 4) + id, _ := database.CreateUser(context.Background(), "ivan", "hash", 4) expires := time.Now().Add(24 * time.Hour) - if err := database.BanUser(id, "temp ban", &expires); err != nil { + if err := database.BanUser(context.Background(), id, "temp ban", &expires); err != nil { t.Fatalf("BanUser (temp): %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if !user.Banned { t.Error("Banned = false after temp ban") } @@ -238,9 +239,9 @@ func TestBanUser_Temporary(t *testing.T) { func TestCreateSession_Success(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("jack", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "jack", "hash", 4) - id, err := database.CreateSession(uid, "tokenHash1", "GoTest/1.0", "127.0.0.1") + id, err := database.CreateSession(context.Background(), uid, "tokenHash1", "GoTest/1.0", "127.0.0.1") if err != nil { t.Fatalf("CreateSession: %v", err) } @@ -251,10 +252,10 @@ func TestCreateSession_Success(t *testing.T) { func TestGetSessionByTokenHash_Found(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("kate", "hash", 4) - _, _ = database.CreateSession(uid, "myTokenHash", "GoTest/1.0", "127.0.0.1") + uid, _ := database.CreateUser(context.Background(), "kate", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "myTokenHash", "GoTest/1.0", "127.0.0.1") - sess, err := database.GetSessionByTokenHash("myTokenHash") + sess, err := database.GetSessionByTokenHash(context.Background(), "myTokenHash") if err != nil { t.Fatalf("GetSessionByTokenHash: %v", err) } @@ -268,7 +269,7 @@ func TestGetSessionByTokenHash_Found(t *testing.T) { func TestGetSessionByTokenHash_NotFound(t *testing.T) { database := newTestDB(t) - sess, err := database.GetSessionByTokenHash("nonexistent") + sess, err := database.GetSessionByTokenHash(context.Background(), "nonexistent") if err != nil { t.Fatalf("GetSessionByTokenHash(not found): %v", err) } @@ -279,10 +280,10 @@ func TestGetSessionByTokenHash_NotFound(t *testing.T) { func TestGetSessionWithBanStatus_Found(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("zara", "hash", 4) - _, _ = database.CreateSession(uid, "banCheckToken", "GoTest/1.0", "127.0.0.1") + uid, _ := database.CreateUser(context.Background(), "zara", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "banCheckToken", "GoTest/1.0", "127.0.0.1") - result, err := database.GetSessionWithBanStatus("banCheckToken") + result, err := database.GetSessionWithBanStatus(context.Background(), "banCheckToken") if err != nil { t.Fatalf("GetSessionWithBanStatus: %v", err) } @@ -299,13 +300,13 @@ func TestGetSessionWithBanStatus_Found(t *testing.T) { func TestGetSessionWithBanStatus_BannedUser(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("banned-zara", "hash", 4) - _, _ = database.CreateSession(uid, "bannedToken", "GoTest/1.0", "127.0.0.1") - if err := database.BanUser(uid, "rule violation", nil); err != nil { + uid, _ := database.CreateUser(context.Background(), "banned-zara", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "bannedToken", "GoTest/1.0", "127.0.0.1") + if err := database.BanUser(context.Background(), uid, "rule violation", nil); err != nil { t.Fatalf("BanUser: %v", err) } - result, err := database.GetSessionWithBanStatus("bannedToken") + result, err := database.GetSessionWithBanStatus(context.Background(), "bannedToken") if err != nil { t.Fatalf("GetSessionWithBanStatus: %v", err) } @@ -322,7 +323,7 @@ func TestGetSessionWithBanStatus_BannedUser(t *testing.T) { func TestGetSessionWithBanStatus_NotFound(t *testing.T) { database := newTestDB(t) - result, err := database.GetSessionWithBanStatus("nonexistent") + result, err := database.GetSessionWithBanStatus(context.Background(), "nonexistent") if err != nil { t.Fatalf("GetSessionWithBanStatus(not found): %v", err) } @@ -333,13 +334,13 @@ func TestGetSessionWithBanStatus_NotFound(t *testing.T) { func TestDeleteSession(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("leo", "hash", 4) - _, _ = database.CreateSession(uid, "delToken", "GoTest/1.0", "127.0.0.1") + uid, _ := database.CreateUser(context.Background(), "leo", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "delToken", "GoTest/1.0", "127.0.0.1") - if err := database.DeleteSession("delToken"); err != nil { + if err := database.DeleteSession(context.Background(), "delToken"); err != nil { t.Fatalf("DeleteSession: %v", err) } - sess, _ := database.GetSessionByTokenHash("delToken") + sess, _ := database.GetSessionByTokenHash(context.Background(), "delToken") if sess != nil { t.Error("Session still exists after DeleteSession") } @@ -347,12 +348,12 @@ func TestDeleteSession(t *testing.T) { func TestDeleteExpiredSessions(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("mia", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "mia", "hash", 4) // Insert an already-expired session directly via Exec. // Use SQLite datetime format (space separator) to match what datetime('now') produces. pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, uid, "expiredToken", "test", "127.0.0.1", pastTime, ) @@ -361,17 +362,17 @@ func TestDeleteExpiredSessions(t *testing.T) { } // Insert a valid session through the normal path. - _, _ = database.CreateSession(uid, "validToken", "GoTest/1.0", "127.0.0.1") + _, _ = database.CreateSession(context.Background(), uid, "validToken", "GoTest/1.0", "127.0.0.1") - if err := database.DeleteExpiredSessions(); err != nil { + if err := database.DeleteExpiredSessions(context.Background()); err != nil { t.Fatalf("DeleteExpiredSessions: %v", err) } - expired, _ := database.GetSessionByTokenHash("expiredToken") + expired, _ := database.GetSessionByTokenHash(context.Background(), "expiredToken") if expired != nil { t.Error("Expired session still exists after DeleteExpiredSessions") } - valid, _ := database.GetSessionByTokenHash("validToken") + valid, _ := database.GetSessionByTokenHash(context.Background(), "validToken") if valid == nil { t.Error("Valid session was deleted by DeleteExpiredSessions") } @@ -379,17 +380,17 @@ func TestDeleteExpiredSessions(t *testing.T) { func TestTouchSession(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("noah", "hash", 4) - _, _ = database.CreateSession(uid, "touchToken", "GoTest/1.0", "127.0.0.1") + uid, _ := database.CreateUser(context.Background(), "noah", "hash", 4) + _, _ = database.CreateSession(context.Background(), uid, "touchToken", "GoTest/1.0", "127.0.0.1") - sess1, _ := database.GetSessionByTokenHash("touchToken") + sess1, _ := database.GetSessionByTokenHash(context.Background(), "touchToken") time.Sleep(2 * time.Millisecond) - if err := database.TouchSession("touchToken"); err != nil { + if err := database.TouchSession(context.Background(), "touchToken"); err != nil { t.Fatalf("TouchSession: %v", err) } - sess2, _ := database.GetSessionByTokenHash("touchToken") + sess2, _ := database.GetSessionByTokenHash(context.Background(), "touchToken") if sess1.LastUsed == sess2.LastUsed { // last_used should have advanced; if they're equal the touch had no effect // (This can be flaky at millisecond resolution, but is a reasonable sanity check.) @@ -401,9 +402,9 @@ func TestTouchSession(t *testing.T) { func TestCreateInvite_Success(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("olivia", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "olivia", "hash", 4) - code, err := database.CreateInvite(uid, 0, nil) + code, err := database.CreateInvite(context.Background(), uid, 0, nil) if err != nil { t.Fatalf("CreateInvite: %v", err) } @@ -414,10 +415,10 @@ func TestCreateInvite_Success(t *testing.T) { func TestGetInvite_Found(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("pedro", "hash", 4) - code, _ := database.CreateInvite(uid, 5, nil) + uid, _ := database.CreateUser(context.Background(), "pedro", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 5, nil) - inv, err := database.GetInvite(code) + inv, err := database.GetInvite(context.Background(), code) if err != nil { t.Fatalf("GetInvite: %v", err) } @@ -434,7 +435,7 @@ func TestGetInvite_Found(t *testing.T) { func TestGetInvite_NotFound(t *testing.T) { database := newTestDB(t) - inv, err := database.GetInvite("bogus") + inv, err := database.GetInvite(context.Background(), "bogus") if err != nil { t.Fatalf("GetInvite(not found): %v", err) } @@ -445,14 +446,14 @@ func TestGetInvite_NotFound(t *testing.T) { func TestRevokeInvite(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("uma", "hash", 4) - code, _ := database.CreateInvite(uid, 0, nil) + uid, _ := database.CreateUser(context.Background(), "uma", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 0, nil) - if err := database.RevokeInvite(code); err != nil { + if err := database.RevokeInvite(context.Background(), code); err != nil { t.Fatalf("RevokeInvite: %v", err) } - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if !inv.Revoked { t.Error("Revoked = false after RevokeInvite, want true") } @@ -460,10 +461,10 @@ func TestRevokeInvite(t *testing.T) { func TestCreateInvite_UnlimitedUses(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("vera", "hash", 4) - code, _ := database.CreateInvite(uid, 0, nil) // 0 = unlimited + uid, _ := database.CreateUser(context.Background(), "vera", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 0, nil) // 0 = unlimited - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.MaxUses != nil { t.Errorf("MaxUses = %v, want nil for unlimited", inv.MaxUses) } @@ -475,14 +476,14 @@ func TestCreateInvite_UnlimitedUses(t *testing.T) { // its use_count incremented in one operation. func TestUseInviteAtomic_Success(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user1", "hash", 4) - code, _ := database.CreateInvite(uid, 0, nil) + uid, _ := database.CreateUser(context.Background(), "atomic_user1", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 0, nil) - if err := database.UseInviteAtomic(code); err != nil { + if err := database.UseInviteAtomic(context.Background(), code); err != nil { t.Fatalf("UseInviteAtomic: %v", err) } - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.Uses != 1 { t.Errorf("Uses = %d, want 1", inv.Uses) } @@ -492,16 +493,16 @@ func TestUseInviteAtomic_Success(t *testing.T) { // multiple sequential calls. func TestUseInviteAtomic_IncrementsUses(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user2", "hash", 4) - code, _ := database.CreateInvite(uid, 5, nil) + uid, _ := database.CreateUser(context.Background(), "atomic_user2", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 5, nil) for i := range 3 { - if err := database.UseInviteAtomic(code); err != nil { + if err := database.UseInviteAtomic(context.Background(), code); err != nil { t.Fatalf("UseInviteAtomic iteration %d: %v", i, err) } } - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.Uses != 3 { t.Errorf("Uses = %d, want 3", inv.Uses) } @@ -511,16 +512,16 @@ func TestUseInviteAtomic_IncrementsUses(t *testing.T) { // modifying the database. func TestUseInviteAtomic_Revoked(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user3", "hash", 4) - code, _ := database.CreateInvite(uid, 0, nil) - _ = database.RevokeInvite(code) + uid, _ := database.CreateUser(context.Background(), "atomic_user3", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 0, nil) + _ = database.RevokeInvite(context.Background(), code) - if err := database.UseInviteAtomic(code); err == nil { + if err := database.UseInviteAtomic(context.Background(), code); err == nil { t.Error("UseInviteAtomic returned nil error for revoked invite, want error") } // use_count must not have changed. - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.Uses != 0 { t.Errorf("Uses = %d after revoked attempt, want 0", inv.Uses) } @@ -529,12 +530,12 @@ func TestUseInviteAtomic_Revoked(t *testing.T) { // TestUseInviteAtomic_Expired returns an error for an expired invite. func TestUseInviteAtomic_Expired(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user4", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "atomic_user4", "hash", 4) past := time.Now().Add(-time.Hour) - code, _ := database.CreateInvite(uid, 0, &past) + code, _ := database.CreateInvite(context.Background(), uid, 0, &past) - if err := database.UseInviteAtomic(code); err == nil { + if err := database.UseInviteAtomic(context.Background(), code); err == nil { t.Error("UseInviteAtomic returned nil error for expired invite, want error") } } @@ -543,13 +544,13 @@ func TestUseInviteAtomic_Expired(t *testing.T) { // reached its maximum use count. func TestUseInviteAtomic_ExceedsMaxUses(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user5", "hash", 4) - code, _ := database.CreateInvite(uid, 1, nil) + uid, _ := database.CreateUser(context.Background(), "atomic_user5", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 1, nil) - if err := database.UseInviteAtomic(code); err != nil { + if err := database.UseInviteAtomic(context.Background(), code); err != nil { t.Fatalf("UseInviteAtomic first use: %v", err) } - if err := database.UseInviteAtomic(code); err == nil { + if err := database.UseInviteAtomic(context.Background(), code); err == nil { t.Error("UseInviteAtomic returned nil error after exceeding max_uses, want error") } } @@ -558,7 +559,7 @@ func TestUseInviteAtomic_ExceedsMaxUses(t *testing.T) { func TestUseInviteAtomic_NotFound(t *testing.T) { database := newTestDB(t) - if err := database.UseInviteAtomic("doesnotexist"); err == nil { + if err := database.UseInviteAtomic(context.Background(), "doesnotexist"); err == nil { t.Error("UseInviteAtomic returned nil error for unknown code, want error") } } @@ -568,15 +569,15 @@ func TestUseInviteAtomic_NotFound(t *testing.T) { // fail; the use_count must end up at 1. func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("atomic_user6", "hash", 4) - code, _ := database.CreateInvite(uid, 1, nil) + uid, _ := database.CreateUser(context.Background(), "atomic_user6", "hash", 4) + code, _ := database.CreateInvite(context.Background(), uid, 1, nil) type result struct{ err error } results := make(chan result, 2) for range 2 { go func() { - results <- result{err: database.UseInviteAtomic(code)} + results <- result{err: database.UseInviteAtomic(context.Background(), code)} }() } @@ -592,7 +593,7 @@ func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) { t.Errorf("concurrent redemptions: %d succeeded, want exactly 1", successes) } - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv.Uses != 1 { t.Errorf("use_count = %d after concurrent race, want 1", inv.Uses) } @@ -602,22 +603,22 @@ func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) { func TestUnbanUser_ClearsBan(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("unban_target", "hash", 4) + id, _ := database.CreateUser(context.Background(), "unban_target", "hash", 4) - if err := database.BanUser(id, "spam", nil); err != nil { + if err := database.BanUser(context.Background(), id, "spam", nil); err != nil { t.Fatalf("BanUser: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if !user.Banned { t.Fatal("user should be banned before unban") } - if err := database.UnbanUser(id); err != nil { + if err := database.UnbanUser(context.Background(), id); err != nil { t.Fatalf("UnbanUser: %v", err) } - user, _ = database.GetUserByID(id) + user, _ = database.GetUserByID(context.Background(), id) if user.Banned { t.Error("Banned = true after UnbanUser, want false") } @@ -633,7 +634,7 @@ func TestUnbanUser_NonexistentUser(t *testing.T) { database := newTestDB(t) // Unbanning nonexistent user should not error. - if err := database.UnbanUser(99999); err != nil { + if err := database.UnbanUser(context.Background(), 99999); err != nil { t.Errorf("UnbanUser(nonexistent) error: %v", err) } } @@ -642,18 +643,18 @@ func TestUnbanUser_NonexistentUser(t *testing.T) { func TestResetAllUserStatuses(t *testing.T) { database := newTestDB(t) - id1, _ := database.CreateUser("status_u1", "hash", 4) - id2, _ := database.CreateUser("status_u2", "hash", 4) + id1, _ := database.CreateUser(context.Background(), "status_u1", "hash", 4) + id2, _ := database.CreateUser(context.Background(), "status_u2", "hash", 4) - _ = database.UpdateUserStatus(id1, "online") - _ = database.UpdateUserStatus(id2, "dnd") + _ = database.UpdateUserStatus(context.Background(), id1, "online") + _ = database.UpdateUserStatus(context.Background(), id2, "dnd") - if err := database.ResetAllUserStatuses(); err != nil { + if err := database.ResetAllUserStatuses(context.Background()); err != nil { t.Fatalf("ResetAllUserStatuses: %v", err) } - u1, _ := database.GetUserByID(id1) - u2, _ := database.GetUserByID(id2) + u1, _ := database.GetUserByID(context.Background(), id1) + u2, _ := database.GetUserByID(context.Background(), id2) if u1.Status != "offline" { t.Errorf("user1 status = %q, want 'offline'", u1.Status) } @@ -664,10 +665,10 @@ func TestResetAllUserStatuses(t *testing.T) { func TestResetAllUserStatuses_AlreadyOffline(t *testing.T) { database := newTestDB(t) - _, _ = database.CreateUser("offline_user", "hash", 4) + _, _ = database.CreateUser(context.Background(), "offline_user", "hash", 4) // Should not error when all users are already offline. - if err := database.ResetAllUserStatuses(); err != nil { + if err := database.ResetAllUserStatuses(context.Background()); err != nil { t.Errorf("ResetAllUserStatuses: %v", err) } } @@ -677,7 +678,7 @@ func TestResetAllUserStatuses_AlreadyOffline(t *testing.T) { func TestListMembers_Empty(t *testing.T) { database := newTestDB(t) - members, err := database.ListMembers() + members, err := database.ListMembers(context.Background()) if err != nil { t.Fatalf("ListMembers: %v", err) } @@ -688,12 +689,12 @@ func TestListMembers_Empty(t *testing.T) { func TestListMembers_ExcludesBanned(t *testing.T) { database := newTestDB(t) - id1, _ := database.CreateUser("member_visible", "hash", 4) - id2, _ := database.CreateUser("member_banned", "hash", 4) - _ = database.BanUser(id2, "test ban", nil) + id1, _ := database.CreateUser(context.Background(), "member_visible", "hash", 4) + id2, _ := database.CreateUser(context.Background(), "member_banned", "hash", 4) + _ = database.BanUser(context.Background(), id2, "test ban", nil) _ = id1 // suppress unused - members, err := database.ListMembers() + members, err := database.ListMembers(context.Background()) if err != nil { t.Fatalf("ListMembers: %v", err) } @@ -710,11 +711,11 @@ func TestListMembers_ExcludesBanned(t *testing.T) { func TestListMembers_SortedByUsername(t *testing.T) { database := newTestDB(t) - _, _ = database.CreateUser("zeta_user", "hash", 4) - _, _ = database.CreateUser("alpha_user", "hash", 4) - _, _ = database.CreateUser("mid_user", "hash", 4) + _, _ = database.CreateUser(context.Background(), "zeta_user", "hash", 4) + _, _ = database.CreateUser(context.Background(), "alpha_user", "hash", 4) + _, _ = database.CreateUser(context.Background(), "mid_user", "hash", 4) - members, err := database.ListMembers() + members, err := database.ListMembers(context.Background()) if err != nil { t.Fatalf("ListMembers: %v", err) } diff --git a/Server/db/backup_test.go b/Server/db/backup_test.go index 5dd45586..1ef48c02 100644 --- a/Server/db/backup_test.go +++ b/Server/db/backup_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "os" "path/filepath" "testing" @@ -43,7 +44,7 @@ func TestBackupToSafe_ValidPath(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } backupPath := filepath.Join(backupDir, "chatserver_20260315_120000.db") - if err := database.BackupToSafe(backupPath, backupDir); err != nil { + if err := database.BackupToSafe(context.Background(), backupPath, backupDir); err != nil { t.Fatalf("BackupToSafe() with valid path returned error: %v", err) } @@ -67,7 +68,7 @@ func TestBackupToSafe_RejectsPathOutsideRoot(t *testing.T) { } // Try to write outside backupDir escapePath := filepath.Join(tmpDir, "escaped.db") - err := database.BackupToSafe(escapePath, backupDir) + err := database.BackupToSafe(context.Background(), escapePath, backupDir) if err == nil { t.Error("BackupToSafe() should reject path outside safe root, got nil") } @@ -83,7 +84,7 @@ func TestBackupToSafe_RejectsSingleQuote(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, "evil'.db") - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with single-quote in path should return error, got nil") } @@ -98,7 +99,7 @@ func TestBackupToSafe_RejectsSemicolon(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, "evil;drop.db") - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with semicolon in path should return error, got nil") } @@ -113,7 +114,7 @@ func TestBackupToSafe_RejectsSQLComment(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, "evil--comment.db") - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with '--' in path should return error, got nil") } @@ -128,7 +129,7 @@ func TestBackupToSafe_RejectsNullByte(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, "evil\x00.db") //nolint:gocritic // intentional null byte for security test - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with null byte in path should return error, got nil") } @@ -144,7 +145,7 @@ func TestBackupToSafe_RejectsDoubleQuote(t *testing.T) { t.Fatalf("MkdirAll: %v", err) } malicious := filepath.Join(backupDir, `evil".db`) - err := database.BackupToSafe(malicious, backupDir) + err := database.BackupToSafe(context.Background(), malicious, backupDir) if err == nil { t.Error("BackupToSafe() with double-quote in path should return error, got nil") } diff --git a/Server/db/block_queries.go b/Server/db/block_queries.go index eea5ae86..4d97a704 100644 --- a/Server/db/block_queries.go +++ b/Server/db/block_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "errors" "fmt" @@ -11,8 +12,8 @@ import ( // BlockUser adds a block from blocker to blocked. Idempotent — re-blocking // a user that is already blocked is a no-op (INSERT OR IGNORE). -func (d *DB) BlockUser(blockerID, blockedID int64) error { - if err := d.q.BlockUser(dbCtx(), dbgen.BlockUserParams{ +func (d *DB) BlockUser(ctx context.Context, blockerID, blockedID int64) error { + if err := d.q.BlockUser(ctx, dbgen.BlockUserParams{ BlockerID: blockerID, BlockedID: blockedID, }); err != nil { @@ -23,8 +24,8 @@ func (d *DB) BlockUser(blockerID, blockedID int64) error { // UnblockUser removes a block. Idempotent — unblocking a non-blocked user is // a no-op. -func (d *DB) UnblockUser(blockerID, blockedID int64) error { - if err := d.q.UnblockUser(dbCtx(), dbgen.UnblockUserParams{ +func (d *DB) UnblockUser(ctx context.Context, blockerID, blockedID int64) error { + if err := d.q.UnblockUser(ctx, dbgen.UnblockUserParams{ BlockerID: blockerID, BlockedID: blockedID, }); err != nil { @@ -34,8 +35,8 @@ func (d *DB) UnblockUser(blockerID, blockedID int64) error { } // IsBlocked returns true if blockerID has blocked blockedID. -func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) { - _, err := d.q.IsBlocked(dbCtx(), dbgen.IsBlockedParams{ +func (d *DB) IsBlocked(ctx context.Context, blockerID, blockedID int64) (bool, error) { + _, err := d.q.IsBlocked(ctx, dbgen.IsBlockedParams{ BlockerID: blockerID, BlockedID: blockedID, }) @@ -51,8 +52,8 @@ func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) { // IsEitherBlocked returns true if either user has blocked the other. // Used for DM authorization — if either party has blocked the other, // messaging is denied. -func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) { - _, err := d.q.IsEitherBlocked(dbCtx(), dbgen.IsEitherBlockedParams{ +func (d *DB) IsEitherBlocked(ctx context.Context, userA, userB int64) (bool, error) { + _, err := d.q.IsEitherBlocked(ctx, dbgen.IsEitherBlockedParams{ BlockerID: userA, BlockedID: userB, BlockerID_2: userB, @@ -68,8 +69,8 @@ func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) { } // ListBlockedUsers returns the IDs of all users blocked by the given user. -func (d *DB) ListBlockedUsers(blockerID int64) ([]int64, error) { - ids, err := d.q.ListBlockedUsers(dbCtx(), blockerID) +func (d *DB) ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) { + ids, err := d.q.ListBlockedUsers(ctx, blockerID) if err != nil { return nil, fmt.Errorf("ListBlockedUsers: %w", err) } diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go index 1350b227..828c50aa 100644 --- a/Server/db/channel_queries.go +++ b/Server/db/channel_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -47,21 +48,21 @@ func channelFromFields(f channelFields) Channel { } // ListChannels returns all channels ordered by position. -func (d *DB) ListChannels() ([]Channel, error) { - rows, err := d.q.ListChannels(dbCtx()) +func (d *DB) ListChannels(ctx context.Context) ([]Channel, error) { + rows, err := d.q.ListChannels(ctx) if err != nil { return nil, fmt.Errorf("ListChannels: %w", err) } channels := make([]Channel, 0, len(rows)) - for _, r := range rows { - channels = append(channels, channelFromFields(channelFields(r))) + for i := range rows { + channels = append(channels, channelFromFields(channelFields(rows[i]))) } return channels, nil } // GetChannel returns the channel with the given id, or nil if not found. -func (d *DB) GetChannel(id int64) (*Channel, error) { - r, err := d.q.GetChannel(dbCtx(), id) +func (d *DB) GetChannel(ctx context.Context, id int64) (*Channel, error) { + r, err := d.q.GetChannel(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -73,8 +74,8 @@ func (d *DB) GetChannel(id int64) (*Channel, error) { } // CreateChannel inserts a new channel and returns the assigned ID. -func (d *DB) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { - res, err := d.q.CreateChannel(dbCtx(), dbgen.CreateChannelParams{ +func (d *DB) CreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) { + res, err := d.q.CreateChannel(ctx, dbgen.CreateChannelParams{ Name: name, Type: chanType, Category: strToNullPtr(category), @@ -88,8 +89,8 @@ func (d *DB) CreateChannel(name, chanType, category, topic string, position int) } // UpdateChannel modifies name, topic, and slow_mode for the given channel. -func (d *DB) UpdateChannel(id int64, name, topic string, slowMode int) error { - if err := d.q.UpdateChannel(dbCtx(), dbgen.UpdateChannelParams{ +func (d *DB) UpdateChannel(ctx context.Context, id int64, name, topic string, slowMode int) error { + if err := d.q.UpdateChannel(ctx, dbgen.UpdateChannelParams{ Name: name, Topic: strToNullPtr(topic), SlowMode: int64(slowMode), @@ -101,8 +102,8 @@ func (d *DB) UpdateChannel(id int64, name, topic string, slowMode int) error { } // SetChannelSlowMode updates only the slow_mode field for the given channel. -func (d *DB) SetChannelSlowMode(id int64, slowMode int) error { - if err := d.q.SetChannelSlowMode(dbCtx(), dbgen.SetChannelSlowModeParams{ +func (d *DB) SetChannelSlowMode(ctx context.Context, id int64, slowMode int) error { + if err := d.q.SetChannelSlowMode(ctx, dbgen.SetChannelSlowModeParams{ SlowMode: int64(slowMode), ID: id, }); err != nil { @@ -112,8 +113,8 @@ func (d *DB) SetChannelSlowMode(id int64, slowMode int) error { } // SetChannelVoiceMaxUsers updates the voice_max_users field for the given channel. -func (d *DB) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { - if err := d.q.SetChannelVoiceMaxUsers(dbCtx(), dbgen.SetChannelVoiceMaxUsersParams{ +func (d *DB) SetChannelVoiceMaxUsers(ctx context.Context, id int64, maxUsers int) error { + if err := d.q.SetChannelVoiceMaxUsers(ctx, dbgen.SetChannelVoiceMaxUsersParams{ VoiceMaxUsers: int64(maxUsers), ID: id, }); err != nil { @@ -123,8 +124,8 @@ func (d *DB) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { } // DeleteChannel removes the channel row (cascades to messages, overrides, etc.). -func (d *DB) DeleteChannel(id int64) error { - if err := d.q.DeleteChannel(dbCtx(), id); err != nil { +func (d *DB) DeleteChannel(ctx context.Context, id int64) error { + if err := d.q.DeleteChannel(ctx, id); err != nil { return fmt.Errorf("DeleteChannel: %w", err) } return nil @@ -132,8 +133,8 @@ func (d *DB) DeleteChannel(id int64) error { // GetChannelPermissions returns the allow/deny override bits for a role on a // channel. Returns (0, 0, nil) when no override exists. -func (d *DB) GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) { - r, scanErr := d.q.GetChannelPermission(dbCtx(), dbgen.GetChannelPermissionParams{ +func (d *DB) GetChannelPermissions(ctx context.Context, channelID, roleID int64) (allow, deny int64, err error) { + r, scanErr := d.q.GetChannelPermission(ctx, dbgen.GetChannelPermissionParams{ ChannelID: channelID, RoleID: roleID, }) @@ -155,8 +156,8 @@ type ChannelOverride struct { // GetAllChannelPermissionsForRole returns all channel permission overrides for // a role in a single query, keyed by channel ID. Eliminates N+1 queries when // filtering channels by permission. -func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOverride, error) { - rows, err := d.q.GetRoleChannelPermissions(dbCtx(), roleID) +func (d *DB) GetAllChannelPermissionsForRole(ctx context.Context, roleID int64) (map[int64]ChannelOverride, error) { + rows, err := d.q.GetRoleChannelPermissions(ctx, roleID) if err != nil { return nil, fmt.Errorf("GetAllChannelPermissionsForRole: %w", err) } @@ -169,8 +170,8 @@ func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOve // UpsertChannelOverride inserts or updates the allow/deny permission override // for a role on a channel. -func (d *DB) UpsertChannelOverride(channelID, roleID, allow, deny int64) error { - if err := d.q.UpsertChannelPermission(dbCtx(), dbgen.UpsertChannelPermissionParams{ +func (d *DB) UpsertChannelOverride(ctx context.Context, channelID, roleID, allow, deny int64) error { + if err := d.q.UpsertChannelPermission(ctx, dbgen.UpsertChannelPermissionParams{ ChannelID: channelID, RoleID: roleID, Allow: allow, @@ -183,8 +184,8 @@ func (d *DB) UpsertChannelOverride(channelID, roleID, allow, deny int64) error { // DeleteChannelOverride removes the permission override for a role on a // channel. Deleting a non-existent override is a no-op. -func (d *DB) DeleteChannelOverride(channelID, roleID int64) error { - if err := d.q.DeleteChannelPermission(dbCtx(), dbgen.DeleteChannelPermissionParams{ +func (d *DB) DeleteChannelOverride(ctx context.Context, channelID, roleID int64) error { + if err := d.q.DeleteChannelPermission(ctx, dbgen.DeleteChannelPermissionParams{ ChannelID: channelID, RoleID: roleID, }); err != nil { @@ -208,8 +209,8 @@ type ChannelRoleOverride struct { // ListChannelRoleOverrides returns every role together with its override bits // on the given channel (zero allow/deny when no override row exists), ordered // by role position descending. -func (d *DB) ListChannelRoleOverrides(channelID int64) ([]ChannelRoleOverride, error) { - rows, err := d.sqlDB.Query( +func (d *DB) ListChannelRoleOverrides(ctx context.Context, channelID int64) ([]ChannelRoleOverride, error) { + rows, err := d.sqlDB.QueryContext(ctx, `SELECT r.id, r.name, r.position, r.permissions, COALESCE(o.allow, 0), COALESCE(o.deny, 0) FROM roles r @@ -243,7 +244,7 @@ func (d *DB) ListChannelRoleOverrides(channelID int64) ([]ChannelRoleOverride, e // GetChannelTypes returns a map of channel ID → type string for the given IDs // in a single query, avoiding N+1 lookups. -func (d *DB) GetChannelTypes(ids []int64) (map[int64]string, error) { +func (d *DB) GetChannelTypes(ctx context.Context, ids []int64) (map[int64]string, error) { if len(ids) == 0 { return map[int64]string{}, nil } @@ -261,7 +262,7 @@ func (d *DB) GetChannelTypes(ids []int64) (map[int64]string, error) { strings.Join(placeholders, ","), ) - rows, err := d.sqlDB.Query(query, args...) + rows, err := d.sqlDB.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("GetChannelTypes query: %w", err) } diff --git a/Server/db/channel_queries_test.go b/Server/db/channel_queries_test.go index eabd8d07..dba7aea9 100644 --- a/Server/db/channel_queries_test.go +++ b/Server/db/channel_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "github.com/owncord/server/db" @@ -21,7 +22,7 @@ func openMigratedMemory(t *testing.T) *db.DB { func TestListChannels_Empty(t *testing.T) { database := openMigratedMemory(t) - channels, err := database.ListChannels() + channels, err := database.ListChannels(context.Background()) if err != nil { t.Fatalf("ListChannels() error: %v", err) } @@ -33,14 +34,14 @@ func TestListChannels_Empty(t *testing.T) { func TestListChannels_ReturnsAll(t *testing.T) { database := openMigratedMemory(t) - if _, err := database.CreateChannel("general", "text", "", "General chat", 0); err != nil { + if _, err := database.CreateChannel(context.Background(), "general", "text", "", "General chat", 0); err != nil { t.Fatalf("CreateChannel general: %v", err) } - if _, err := database.CreateChannel("announcements", "text", "", "", 1); err != nil { + if _, err := database.CreateChannel(context.Background(), "announcements", "text", "", "", 1); err != nil { t.Fatalf("CreateChannel announcements: %v", err) } - channels, err := database.ListChannels() + channels, err := database.ListChannels(context.Background()) if err != nil { t.Fatalf("ListChannels() error: %v", err) } @@ -54,7 +55,7 @@ func TestListChannels_ReturnsAll(t *testing.T) { func TestGetChannel_NotFound(t *testing.T) { database := openMigratedMemory(t) - ch, err := database.GetChannel(9999) + ch, err := database.GetChannel(context.Background(), 9999) if err != nil { t.Fatalf("GetChannel() error: %v", err) } @@ -66,12 +67,12 @@ func TestGetChannel_NotFound(t *testing.T) { func TestGetChannel_Found(t *testing.T) { database := openMigratedMemory(t) - id, err := database.CreateChannel("general", "text", "Public", "hello", 0) + id, err := database.CreateChannel(context.Background(), "general", "text", "Public", "hello", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel: %v", err) } @@ -100,7 +101,7 @@ func TestGetChannel_Found(t *testing.T) { func TestCreateChannel_ReturnsID(t *testing.T) { database := openMigratedMemory(t) - id, err := database.CreateChannel("test", "text", "", "", 0) + id, err := database.CreateChannel(context.Background(), "test", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -112,8 +113,8 @@ func TestCreateChannel_ReturnsID(t *testing.T) { func TestCreateChannel_UniqueIDs(t *testing.T) { database := openMigratedMemory(t) - id1, _ := database.CreateChannel("ch1", "text", "", "", 0) - id2, _ := database.CreateChannel("ch2", "text", "", "", 1) + id1, _ := database.CreateChannel(context.Background(), "ch1", "text", "", "", 0) + id2, _ := database.CreateChannel(context.Background(), "ch2", "text", "", "", 1) if id1 == id2 { t.Error("expected different IDs for different channels") } @@ -122,11 +123,11 @@ func TestCreateChannel_UniqueIDs(t *testing.T) { func TestCreateChannel_EmptyCategory(t *testing.T) { database := openMigratedMemory(t) - id, err := database.CreateChannel("nocategory", "text", "", "", 0) + id, err := database.CreateChannel(context.Background(), "nocategory", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel with empty category: %v", err) } - ch, _ := database.GetChannel(id) + ch, _ := database.GetChannel(context.Background(), id) if ch.Category != "" { t.Errorf("Category = %q, want ''", ch.Category) } @@ -137,13 +138,13 @@ func TestCreateChannel_EmptyCategory(t *testing.T) { func TestUpdateChannel_ChangesNameAndTopic(t *testing.T) { database := openMigratedMemory(t) - id, _ := database.CreateChannel("old", "text", "", "old topic", 0) + id, _ := database.CreateChannel(context.Background(), "old", "text", "", "old topic", 0) - if err := database.UpdateChannel(id, "new", "new topic", 5); err != nil { + if err := database.UpdateChannel(context.Background(), id, "new", "new topic", 5); err != nil { t.Fatalf("UpdateChannel: %v", err) } - ch, _ := database.GetChannel(id) + ch, _ := database.GetChannel(context.Background(), id) if ch.Name != "new" { t.Errorf("Name = %q, want 'new'", ch.Name) } @@ -158,7 +159,7 @@ func TestUpdateChannel_ChangesNameAndTopic(t *testing.T) { func TestUpdateChannel_NonExistent(t *testing.T) { database := openMigratedMemory(t) // Should not error even for non-existent row (0 rows affected is still ok). - err := database.UpdateChannel(9999, "x", "y", 0) + err := database.UpdateChannel(context.Background(), 9999, "x", "y", 0) if err != nil { t.Errorf("UpdateChannel non-existent should not error: %v", err) } @@ -169,13 +170,13 @@ func TestUpdateChannel_NonExistent(t *testing.T) { func TestDeleteChannel_RemovesChannel(t *testing.T) { database := openMigratedMemory(t) - id, _ := database.CreateChannel("todelete", "text", "", "", 0) + id, _ := database.CreateChannel(context.Background(), "todelete", "text", "", "", 0) - if err := database.DeleteChannel(id); err != nil { + if err := database.DeleteChannel(context.Background(), id); err != nil { t.Fatalf("DeleteChannel: %v", err) } - ch, err := database.GetChannel(id) + ch, err := database.GetChannel(context.Background(), id) if err != nil { t.Fatalf("GetChannel after delete: %v", err) } @@ -186,7 +187,7 @@ func TestDeleteChannel_RemovesChannel(t *testing.T) { func TestDeleteChannel_NonExistent(t *testing.T) { database := openMigratedMemory(t) - err := database.DeleteChannel(9999) + err := database.DeleteChannel(context.Background(), 9999) if err != nil { t.Errorf("DeleteChannel non-existent should not error: %v", err) } @@ -197,10 +198,10 @@ func TestDeleteChannel_NonExistent(t *testing.T) { func TestGetChannelPermissions_Default(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("perms", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "perms", "text", "", "", 0) // No override set — should return 0, 0. - allow, deny, err := database.GetChannelPermissions(chID, 4) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -212,9 +213,9 @@ func TestGetChannelPermissions_Default(t *testing.T) { func TestGetChannelPermissions_WithOverride(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("perms2", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "perms2", "text", "", "", 0) // Insert an override directly. - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`, chID, 4, int64(0x400), int64(0x200), ) @@ -222,7 +223,7 @@ func TestGetChannelPermissions_WithOverride(t *testing.T) { t.Fatalf("insert override: %v", err) } - allow, deny, err := database.GetChannelPermissions(chID, 4) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -238,12 +239,12 @@ func TestGetChannelPermissions_WithOverride(t *testing.T) { func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("private", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "private", "text", "", "", 0) - if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 4, 0, 0x202); err != nil { t.Fatalf("UpsertChannelOverride insert: %v", err) } - allow, deny, err := database.GetChannelPermissions(chID, 4) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -252,10 +253,10 @@ func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) { } // Upsert again with different bits — must update, not duplicate. - if err := database.UpsertChannelOverride(chID, 4, 0x2, 0x200); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 4, 0x2, 0x200); err != nil { t.Fatalf("UpsertChannelOverride update: %v", err) } - allow, deny, err = database.GetChannelPermissions(chID, 4) + allow, deny, err = database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -266,15 +267,15 @@ func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) { func TestDeleteChannelOverride(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("private2", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "private2", "text", "", "", 0) - if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 4, 0, 0x202); err != nil { t.Fatalf("UpsertChannelOverride: %v", err) } - if err := database.DeleteChannelOverride(chID, 4); err != nil { + if err := database.DeleteChannelOverride(context.Background(), chID, 4); err != nil { t.Fatalf("DeleteChannelOverride: %v", err) } - allow, deny, err := database.GetChannelPermissions(chID, 4) + allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 4) if err != nil { t.Fatalf("GetChannelPermissions: %v", err) } @@ -283,20 +284,20 @@ func TestDeleteChannelOverride(t *testing.T) { } // Deleting again is a no-op. - if err := database.DeleteChannelOverride(chID, 4); err != nil { + if err := database.DeleteChannelOverride(context.Background(), chID, 4); err != nil { t.Errorf("DeleteChannelOverride non-existent should not error: %v", err) } } func TestListChannelRoleOverrides(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("private3", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "private3", "text", "", "", 0) - if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, 4, 0, 0x202); err != nil { t.Fatalf("UpsertChannelOverride: %v", err) } - overrides, err := database.ListChannelRoleOverrides(chID) + overrides, err := database.ListChannelRoleOverrides(context.Background(), chID) if err != nil { t.Fatalf("ListChannelRoleOverrides: %v", err) } @@ -328,13 +329,13 @@ func TestListChannelRoleOverrides(t *testing.T) { func TestSetChannelSlowMode(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("slowch", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "slowch", "text", "", "", 0) - if err := database.SetChannelSlowMode(chID, 10); err != nil { + if err := database.SetChannelSlowMode(context.Background(), chID, 10); err != nil { t.Fatalf("SetChannelSlowMode: %v", err) } - ch, _ := database.GetChannel(chID) + ch, _ := database.GetChannel(context.Background(), chID) if ch.SlowMode != 10 { t.Errorf("SlowMode = %d, want 10", ch.SlowMode) } @@ -342,12 +343,12 @@ func TestSetChannelSlowMode(t *testing.T) { func TestSetChannelSlowMode_Zero(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("slowch2", "text", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "slowch2", "text", "", "", 0) - _ = database.SetChannelSlowMode(chID, 30) - _ = database.SetChannelSlowMode(chID, 0) + _ = database.SetChannelSlowMode(context.Background(), chID, 30) + _ = database.SetChannelSlowMode(context.Background(), chID, 0) - ch, _ := database.GetChannel(chID) + ch, _ := database.GetChannel(context.Background(), chID) if ch.SlowMode != 0 { t.Errorf("SlowMode = %d, want 0 (disabled)", ch.SlowMode) } @@ -357,13 +358,13 @@ func TestSetChannelSlowMode_Zero(t *testing.T) { func TestSetChannelVoiceMaxUsers(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("voicech", "voice", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "voicech", "voice", "", "", 0) - if err := database.SetChannelVoiceMaxUsers(chID, 25); err != nil { + if err := database.SetChannelVoiceMaxUsers(context.Background(), chID, 25); err != nil { t.Fatalf("SetChannelVoiceMaxUsers: %v", err) } - ch, _ := database.GetChannel(chID) + ch, _ := database.GetChannel(context.Background(), chID) if ch.VoiceMaxUsers != 25 { t.Errorf("VoiceMaxUsers = %d, want 25", ch.VoiceMaxUsers) } @@ -371,12 +372,12 @@ func TestSetChannelVoiceMaxUsers(t *testing.T) { func TestSetChannelVoiceMaxUsers_Unlimited(t *testing.T) { database := openMigratedMemory(t) - chID, _ := database.CreateChannel("voicech2", "voice", "", "", 0) + chID, _ := database.CreateChannel(context.Background(), "voicech2", "voice", "", "", 0) - _ = database.SetChannelVoiceMaxUsers(chID, 10) - _ = database.SetChannelVoiceMaxUsers(chID, 0) + _ = database.SetChannelVoiceMaxUsers(context.Background(), chID, 10) + _ = database.SetChannelVoiceMaxUsers(context.Background(), chID, 0) - ch, _ := database.GetChannel(chID) + ch, _ := database.GetChannel(context.Background(), chID) if ch.VoiceMaxUsers != 0 { t.Errorf("VoiceMaxUsers = %d, want 0 (unlimited)", ch.VoiceMaxUsers) } diff --git a/Server/db/coverage_boost_test.go b/Server/db/coverage_boost_test.go index 6202bafc..c4cea5ab 100644 --- a/Server/db/coverage_boost_test.go +++ b/Server/db/coverage_boost_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "time" @@ -14,12 +15,12 @@ func TestVoice_JoinVoiceChannelIfCapacity_UnderLimit(t *testing.T) { u1 := seedVoiceUser(t, database, "cap-u1") chanID := seedVoiceChannel(t, database, "cap-ch") - err := database.JoinVoiceChannelIfCapacity(u1, chanID, 2) + err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2) if err != nil { t.Fatalf("JoinVoiceChannelIfCapacity: %v", err) } - state, err := database.GetVoiceState(u1) + state, err := database.GetVoiceState(context.Background(), u1) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -39,15 +40,15 @@ func TestVoice_JoinVoiceChannelIfCapacity_AtLimit(t *testing.T) { chanID := seedVoiceChannel(t, database, "cap-full-ch") // Fill channel to capacity (max 2). - if err := database.JoinVoiceChannelIfCapacity(u1, chanID, 2); err != nil { + if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2); err != nil { t.Fatalf("first join: %v", err) } - if err := database.JoinVoiceChannelIfCapacity(u2, chanID, 2); err != nil { + if err := database.JoinVoiceChannelIfCapacity(context.Background(), u2, chanID, 2); err != nil { t.Fatalf("second join: %v", err) } // Third join should fail with ErrChannelFull. - err := database.JoinVoiceChannelIfCapacity(u3, chanID, 2) + err := database.JoinVoiceChannelIfCapacity(context.Background(), u3, chanID, 2) if err == nil { t.Fatal("expected ErrChannelFull, got nil") } @@ -63,14 +64,14 @@ func TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(t *testing.T) { ch2 := seedVoiceChannel(t, database, "cap-ch2") // Join ch1, then join ch2 with capacity check — should replace. - if err := database.JoinVoiceChannelIfCapacity(u1, ch1, 5); err != nil { + if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, ch1, 5); err != nil { t.Fatalf("join ch1: %v", err) } - if err := database.JoinVoiceChannelIfCapacity(u1, ch2, 5); err != nil { + if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, ch2, 5); err != nil { t.Fatalf("join ch2: %v", err) } - state, _ := database.GetVoiceState(u1) + state, _ := database.GetVoiceState(context.Background(), u1) if state == nil || state.ChannelID != ch2 { t.Errorf("expected channel %d, got %v", ch2, state) } @@ -81,7 +82,7 @@ func TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(t *testing.T) { func TestVoice_GetAllVoiceStates_Empty(t *testing.T) { database := newVoiceTestDB(t) - states, err := database.GetAllVoiceStates() + states, err := database.GetAllVoiceStates(context.Background()) if err != nil { t.Fatalf("GetAllVoiceStates: %v", err) } @@ -98,11 +99,11 @@ func TestVoice_GetAllVoiceStates_MultipleChannels(t *testing.T) { ch1 := seedVoiceChannel(t, database, "all-vs-ch1") ch2 := seedVoiceChannel(t, database, "all-vs-ch2") - _ = database.JoinVoiceChannel(u1, ch1) - _ = database.JoinVoiceChannel(u2, ch1) - _ = database.JoinVoiceChannel(u3, ch2) + _ = database.JoinVoiceChannel(context.Background(), u1, ch1) + _ = database.JoinVoiceChannel(context.Background(), u2, ch1) + _ = database.JoinVoiceChannel(context.Background(), u3, ch2) - states, err := database.GetAllVoiceStates() + states, err := database.GetAllVoiceStates(context.Background()) if err != nil { t.Fatalf("GetAllVoiceStates: %v", err) } @@ -117,7 +118,7 @@ func TestVoice_CountActiveCameras_Zero(t *testing.T) { database := newVoiceTestDB(t) chanID := seedVoiceChannel(t, database, "cam-count-empty") - count, err := database.CountActiveCameras(chanID) + count, err := database.CountActiveCameras(context.Background(), chanID) if err != nil { t.Fatalf("CountActiveCameras: %v", err) } @@ -133,15 +134,15 @@ func TestVoice_CountActiveCameras_SomeCameras(t *testing.T) { u3 := seedVoiceUser(t, database, "cam-cnt-u3") chanID := seedVoiceChannel(t, database, "cam-cnt-ch") - _ = database.JoinVoiceChannel(u1, chanID) - _ = database.JoinVoiceChannel(u2, chanID) - _ = database.JoinVoiceChannel(u3, chanID) + _ = database.JoinVoiceChannel(context.Background(), u1, chanID) + _ = database.JoinVoiceChannel(context.Background(), u2, chanID) + _ = database.JoinVoiceChannel(context.Background(), u3, chanID) - _ = database.UpdateVoiceCamera(u1, true) - _ = database.UpdateVoiceCamera(u2, true) + _ = database.UpdateVoiceCamera(context.Background(), u1, true) + _ = database.UpdateVoiceCamera(context.Background(), u2, true) // u3 camera stays off. - count, err := database.CountActiveCameras(chanID) + count, err := database.CountActiveCameras(context.Background(), chanID) if err != nil { t.Fatalf("CountActiveCameras: %v", err) } @@ -157,9 +158,9 @@ func TestVoice_EnableCameraIfUnderLimit_Success(t *testing.T) { u1 := seedVoiceUser(t, database, "cam-limit-ok") chanID := seedVoiceChannel(t, database, "cam-limit-ch") - _ = database.JoinVoiceChannel(u1, chanID) + _ = database.JoinVoiceChannel(context.Background(), u1, chanID) - ok, err := database.EnableCameraIfUnderLimit(u1, chanID, 2) + ok, err := database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 2) if err != nil { t.Fatalf("EnableCameraIfUnderLimit: %v", err) } @@ -167,7 +168,7 @@ func TestVoice_EnableCameraIfUnderLimit_Success(t *testing.T) { t.Error("expected camera to be enabled") } - state, _ := database.GetVoiceState(u1) + state, _ := database.GetVoiceState(context.Background(), u1) if state == nil || !state.Camera { t.Error("camera should be true after enable") } @@ -180,16 +181,16 @@ func TestVoice_EnableCameraIfUnderLimit_AtLimit(t *testing.T) { u3 := seedVoiceUser(t, database, "cam-lim-u3") chanID := seedVoiceChannel(t, database, "cam-lim-ch") - _ = database.JoinVoiceChannel(u1, chanID) - _ = database.JoinVoiceChannel(u2, chanID) - _ = database.JoinVoiceChannel(u3, chanID) + _ = database.JoinVoiceChannel(context.Background(), u1, chanID) + _ = database.JoinVoiceChannel(context.Background(), u2, chanID) + _ = database.JoinVoiceChannel(context.Background(), u3, chanID) // Enable cameras for u1 and u2 (max is 2). - _, _ = database.EnableCameraIfUnderLimit(u1, chanID, 2) - _, _ = database.EnableCameraIfUnderLimit(u2, chanID, 2) + _, _ = database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 2) + _, _ = database.EnableCameraIfUnderLimit(context.Background(), u2, chanID, 2) // u3 should be denied. - ok, err := database.EnableCameraIfUnderLimit(u3, chanID, 2) + ok, err := database.EnableCameraIfUnderLimit(context.Background(), u3, chanID, 2) if err != nil { t.Fatalf("EnableCameraIfUnderLimit: %v", err) } @@ -207,12 +208,12 @@ func TestSearchMessagesInChannels_FindsInAllowedChannels(t *testing.T) { ch2 := seedChannel(t, database, "srch-ch2") ch3 := seedChannel(t, database, "srch-ch3") - _, _ = database.CreateMessage(ch1, userID, "alpha keyword here", nil) - _, _ = database.CreateMessage(ch2, userID, "beta keyword here", nil) - _, _ = database.CreateMessage(ch3, userID, "gamma keyword here", nil) + _, _ = database.CreateMessage(context.Background(), ch1, userID, "alpha keyword here", nil) + _, _ = database.CreateMessage(context.Background(), ch2, userID, "beta keyword here", nil) + _, _ = database.CreateMessage(context.Background(), ch3, userID, "gamma keyword here", nil) // Search only in ch1 and ch2. - results, err := database.SearchMessagesInChannels("keyword", []int64{ch1, ch2}, 10) + results, err := database.SearchMessagesInChannels(context.Background(), "keyword", []int64{ch1, ch2}, 10) if err != nil { t.Fatalf("SearchMessagesInChannels: %v", err) } @@ -229,7 +230,7 @@ func TestSearchMessagesInChannels_FindsInAllowedChannels(t *testing.T) { func TestSearchMessagesInChannels_EmptyQuery(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessagesInChannels("", []int64{1}, 10) + results, err := database.SearchMessagesInChannels(context.Background(), "", []int64{1}, 10) if err != nil { t.Fatalf("SearchMessagesInChannels: %v", err) } @@ -241,7 +242,7 @@ func TestSearchMessagesInChannels_EmptyQuery(t *testing.T) { func TestSearchMessagesInChannels_EmptyChannelIDs(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessagesInChannels("test", nil, 10) + results, err := database.SearchMessagesInChannels(context.Background(), "test", nil, 10) if err != nil { t.Fatalf("SearchMessagesInChannels: %v", err) } @@ -256,10 +257,10 @@ func TestSearchMessagesInChannels_LimitRespected(t *testing.T) { ch1 := seedChannel(t, database, "srch-lim-ch") for range 5 { - _, _ = database.CreateMessage(ch1, userID, "findme content here", nil) + _, _ = database.CreateMessage(context.Background(), ch1, userID, "findme content here", nil) } - results, err := database.SearchMessagesInChannels("findme", []int64{ch1}, 2) + results, err := database.SearchMessagesInChannels(context.Background(), "findme", []int64{ch1}, 2) if err != nil { t.Fatalf("SearchMessagesInChannels: %v", err) } @@ -271,7 +272,7 @@ func TestSearchMessagesInChannels_LimitRespected(t *testing.T) { func TestSearchMessagesInChannels_ZeroLimit(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessagesInChannels("test", []int64{1}, 0) + results, err := database.SearchMessagesInChannels(context.Background(), "test", []int64{1}, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -287,7 +288,7 @@ func TestGetPinnedMessages_Empty(t *testing.T) { userID := seedUser(t, database, "pin-empty-u") chID := seedChannel(t, database, "pin-empty") - msgs, err := database.GetPinnedMessages(chID, userID) + msgs, err := database.GetPinnedMessages(context.Background(), chID, userID) if err != nil { t.Fatalf("GetPinnedMessages: %v", err) } @@ -301,11 +302,11 @@ func TestGetPinnedMessages_ReturnsPinnedOnly(t *testing.T) { userID := seedUser(t, database, "pin-user") chID := seedChannel(t, database, "pin-ch") - id1, _ := database.CreateMessage(chID, userID, "pinned msg", nil) - _, _ = database.CreateMessage(chID, userID, "not pinned", nil) - _ = database.SetMessagePinned(id1, true) + id1, _ := database.CreateMessage(context.Background(), chID, userID, "pinned msg", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "not pinned", nil) + _ = database.SetMessagePinned(context.Background(), id1, true) - msgs, err := database.GetPinnedMessages(chID, userID) + msgs, err := database.GetPinnedMessages(context.Background(), chID, userID) if err != nil { t.Fatalf("GetPinnedMessages: %v", err) } @@ -327,13 +328,13 @@ func TestSetMessagePinned_Pin(t *testing.T) { userID := seedUser(t, database, "setpin-u") chID := seedChannel(t, database, "setpin-ch") - id, _ := database.CreateMessage(chID, userID, "to pin", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "to pin", nil) - if err := database.SetMessagePinned(id, true); err != nil { + if err := database.SetMessagePinned(context.Background(), id, true); err != nil { t.Fatalf("SetMessagePinned(true): %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if msg == nil || !msg.Pinned { t.Error("message should be pinned") } @@ -344,13 +345,13 @@ func TestSetMessagePinned_Unpin(t *testing.T) { userID := seedUser(t, database, "unpin-u") chID := seedChannel(t, database, "unpin-ch") - id, _ := database.CreateMessage(chID, userID, "to unpin", nil) - _ = database.SetMessagePinned(id, true) - if err := database.SetMessagePinned(id, false); err != nil { + id, _ := database.CreateMessage(context.Background(), chID, userID, "to unpin", nil) + _ = database.SetMessagePinned(context.Background(), id, true) + if err := database.SetMessagePinned(context.Background(), id, false); err != nil { t.Fatalf("SetMessagePinned(false): %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if msg == nil || msg.Pinned { t.Error("message should not be pinned") } @@ -359,7 +360,7 @@ func TestSetMessagePinned_Unpin(t *testing.T) { func TestSetMessagePinned_NotFound(t *testing.T) { database := openMigratedMemory(t) - err := database.SetMessagePinned(99999, true) + err := database.SetMessagePinned(context.Background(), 99999, true) if err == nil { t.Error("expected error for non-existent message") } @@ -370,10 +371,10 @@ func TestSetMessagePinned_DeletedMessage(t *testing.T) { userID := seedUser(t, database, "pin-del-u") chID := seedChannel(t, database, "pin-del-ch") - id, _ := database.CreateMessage(chID, userID, "deleted", nil) - _ = database.DeleteMessage(id, userID, false) + id, _ := database.CreateMessage(context.Background(), chID, userID, "deleted", nil) + _ = database.DeleteMessage(context.Background(), id, userID, false) - err := database.SetMessagePinned(id, true) + err := database.SetMessagePinned(context.Background(), id, true) if err == nil { t.Error("expected error when pinning deleted message") } @@ -385,12 +386,12 @@ func TestCreateAttachment_Success(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "att-uploader") - err := database.CreateAttachment("att-001", userID, "photo.png", "stored-001.png", "image/png", 12345, nil, nil) + err := database.CreateAttachment(context.Background(), "att-001", userID, "photo.png", "stored-001.png", "image/png", 12345, nil, nil) if err != nil { t.Fatalf("CreateAttachment: %v", err) } - att, err := database.GetAttachmentByID("att-001") + att, err := database.GetAttachmentByID(context.Background(), "att-001") if err != nil { t.Fatalf("GetAttachmentByID: %v", err) } @@ -413,12 +414,12 @@ func TestCreateAttachment_WithDimensions(t *testing.T) { userID := seedUser(t, database, "att-dim-uploader") w, h := 1920, 1080 - err := database.CreateAttachment("att-dim", userID, "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h) + err := database.CreateAttachment(context.Background(), "att-dim", userID, "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h) if err != nil { t.Fatalf("CreateAttachment with dims: %v", err) } - att, _ := database.GetAttachmentByID("att-dim") + att, _ := database.GetAttachmentByID(context.Background(), "att-dim") if att == nil { t.Fatal("expected attachment") } @@ -431,10 +432,10 @@ func TestDeleteOrphanedAttachments_RemovesOrphans(t *testing.T) { userID := seedUser(t, database, "orphan-uploader") // Create an unlinked attachment (message_id IS NULL). - _ = database.CreateAttachment("orphan-1", userID, "file.txt", "stored-orphan.txt", "text/plain", 100, nil, nil) + _ = database.CreateAttachment(context.Background(), "orphan-1", userID, "file.txt", "stored-orphan.txt", "text/plain", 100, nil, nil) // Use a cutoff far in the future so the attachment is considered old. - files, err := database.DeleteOrphanedAttachments("2099-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), "2099-01-01T00:00:00Z") if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } @@ -446,7 +447,7 @@ func TestDeleteOrphanedAttachments_RemovesOrphans(t *testing.T) { } // Should be removed from DB. - att, _ := database.GetAttachmentByID("orphan-1") + att, _ := database.GetAttachmentByID(context.Background(), "orphan-1") if att != nil { t.Error("orphaned attachment should be deleted from DB") } @@ -458,11 +459,11 @@ func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) { chID := seedChannel(t, database, "orphan-linked-ch") // Create attachment and link it to a message. - _ = database.CreateAttachment("linked-1", userID, "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil) - msgID, _ := database.CreateMessage(chID, userID, "with attachment", nil) - _, _ = database.LinkAttachmentsToMessage(msgID, userID, []string{"linked-1"}) + _ = database.CreateAttachment(context.Background(), "linked-1", userID, "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "with attachment", nil) + _, _ = database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"linked-1"}) - files, err := database.DeleteOrphanedAttachments("2099-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), "2099-01-01T00:00:00Z") if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } @@ -475,10 +476,10 @@ func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "cutoff-uploader") - _ = database.CreateAttachment("future-1", userID, "file.txt", "stored-future.txt", "text/plain", 100, nil, nil) + _ = database.CreateAttachment(context.Background(), "future-1", userID, "file.txt", "stored-future.txt", "text/plain", 100, nil, nil) // Cutoff in the past — newly created attachment should NOT be deleted. - files, err := database.DeleteOrphanedAttachments("2000-01-01T00:00:00Z") + files, err := database.DeleteOrphanedAttachments(context.Background(), "2000-01-01T00:00:00Z") if err != nil { t.Fatalf("DeleteOrphanedAttachments: %v", err) } @@ -492,7 +493,7 @@ func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) { func TestGetAllChannelPermissionsForRole_Empty(t *testing.T) { database := openMigratedMemory(t) - result, err := database.GetAllChannelPermissionsForRole(4) + result, err := database.GetAllChannelPermissionsForRole(context.Background(), 4) if err != nil { t.Fatalf("GetAllChannelPermissionsForRole: %v", err) } @@ -504,20 +505,20 @@ func TestGetAllChannelPermissionsForRole_Empty(t *testing.T) { func TestGetAllChannelPermissionsForRole_WithOverrides(t *testing.T) { database := openMigratedMemory(t) - ch1, _ := database.CreateChannel("perm-ch1", "text", "", "", 0) - ch2, _ := database.CreateChannel("perm-ch2", "text", "", "", 0) + ch1, _ := database.CreateChannel(context.Background(), "perm-ch1", "text", "", "", 0) + ch2, _ := database.CreateChannel(context.Background(), "perm-ch2", "text", "", "", 0) // Insert overrides for role 4. - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`, ch1, 4, int64(0x100), int64(0x200), ) - _, _ = database.Exec( + _, _ = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`, ch2, 4, int64(0x300), int64(0), ) - result, err := database.GetAllChannelPermissionsForRole(4) + result, err := database.GetAllChannelPermissionsForRole(context.Background(), 4) if err != nil { t.Fatalf("GetAllChannelPermissionsForRole: %v", err) } @@ -534,7 +535,7 @@ func TestGetAllChannelPermissionsForRole_WithOverrides(t *testing.T) { func TestGetChannelTypes_Empty(t *testing.T) { database := openMigratedMemory(t) - result, err := database.GetChannelTypes(nil) + result, err := database.GetChannelTypes(context.Background(), nil) if err != nil { t.Fatalf("GetChannelTypes: %v", err) } @@ -546,10 +547,10 @@ func TestGetChannelTypes_Empty(t *testing.T) { func TestGetChannelTypes_ReturnsTypes(t *testing.T) { database := openMigratedMemory(t) - ch1, _ := database.CreateChannel("type-text", "text", "", "", 0) - ch2, _ := database.CreateChannel("type-voice", "voice", "", "", 0) + ch1, _ := database.CreateChannel(context.Background(), "type-text", "text", "", "", 0) + ch2, _ := database.CreateChannel(context.Background(), "type-voice", "voice", "", "", 0) - result, err := database.GetChannelTypes([]int64{ch1, ch2}) + result, err := database.GetChannelTypes(context.Background(), []int64{ch1, ch2}) if err != nil { t.Fatalf("GetChannelTypes: %v", err) } @@ -564,7 +565,7 @@ func TestGetChannelTypes_ReturnsTypes(t *testing.T) { func TestGetChannelTypes_NonExistentIDs(t *testing.T) { database := openMigratedMemory(t) - result, err := database.GetChannelTypes([]int64{99999}) + result, err := database.GetChannelTypes(context.Background(), []int64{99999}) if err != nil { t.Fatalf("GetChannelTypes: %v", err) } @@ -577,10 +578,10 @@ func TestGetChannelTypes_NonExistentIDs(t *testing.T) { func TestCountUsersWithoutTOTP_AllWithout(t *testing.T) { database := openMigratedMemory(t) - _, _ = database.CreateUser("totp-u1", "hash", 4) - _, _ = database.CreateUser("totp-u2", "hash", 4) + _, _ = database.CreateUser(context.Background(), "totp-u1", "hash", 4) + _, _ = database.CreateUser(context.Background(), "totp-u2", "hash", 4) - count, err := database.CountUsersWithoutTOTP() + count, err := database.CountUsersWithoutTOTP(context.Background()) if err != nil { t.Fatalf("CountUsersWithoutTOTP: %v", err) } @@ -591,13 +592,13 @@ func TestCountUsersWithoutTOTP_AllWithout(t *testing.T) { func TestCountUsersWithoutTOTP_WithTOTPSetup(t *testing.T) { database := openMigratedMemory(t) - uid, _ := database.CreateUser("totp-with", "hash", 4) - _, _ = database.CreateUser("totp-without", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "totp-with", "hash", 4) + _, _ = database.CreateUser(context.Background(), "totp-without", "hash", 4) secret := "JBSWY3DPEHPK3PXP" - _ = database.UpdateUserTOTPSecret(uid, &secret) + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) - count, err := database.CountUsersWithoutTOTP() + count, err := database.CountUsersWithoutTOTP(context.Background()) if err != nil { t.Fatalf("CountUsersWithoutTOTP: %v", err) } @@ -610,14 +611,14 @@ func TestCountUsersWithoutTOTP_WithTOTPSetup(t *testing.T) { func TestUpdateUserTOTPSecret_Set(t *testing.T) { database := openMigratedMemory(t) - uid, _ := database.CreateUser("totp-set", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "totp-set", "hash", 4) secret := "JBSWY3DPEHPK3PXP" - if err := database.UpdateUserTOTPSecret(uid, &secret); err != nil { + if err := database.UpdateUserTOTPSecret(context.Background(), uid, &secret); err != nil { t.Fatalf("UpdateUserTOTPSecret(set): %v", err) } - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user == nil || user.TOTPSecret == nil || *user.TOTPSecret != secret { t.Error("TOTP secret should be set") } @@ -625,15 +626,15 @@ func TestUpdateUserTOTPSecret_Set(t *testing.T) { func TestUpdateUserTOTPSecret_Clear(t *testing.T) { database := openMigratedMemory(t) - uid, _ := database.CreateUser("totp-clear", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "totp-clear", "hash", 4) secret := "JBSWY3DPEHPK3PXP" - _ = database.UpdateUserTOTPSecret(uid, &secret) - if err := database.UpdateUserTOTPSecret(uid, nil); err != nil { + _ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret) + if err := database.UpdateUserTOTPSecret(context.Background(), uid, nil); err != nil { t.Fatalf("UpdateUserTOTPSecret(clear): %v", err) } - user, _ := database.GetUserByID(uid) + user, _ := database.GetUserByID(context.Background(), uid) if user == nil || user.TOTPSecret != nil { t.Error("TOTP secret should be nil after clear") } @@ -644,14 +645,14 @@ func TestUpdateUserTOTPSecret_Clear(t *testing.T) { func TestCreateUserWithInvite_Success(t *testing.T) { database := openMigratedMemory(t) // Create a user who will create the invite. - creatorID, _ := database.CreateUser("invite-creator", "hash", 2) + creatorID, _ := database.CreateUser(context.Background(), "invite-creator", "hash", 2) - code, err := database.CreateInvite(creatorID, 5, nil) + code, err := database.CreateInvite(context.Background(), creatorID, 5, nil) if err != nil { t.Fatalf("CreateInvite: %v", err) } - uid, err := database.CreateUserWithInvite("newuser", "hash", 4, code) + uid, err := database.CreateUserWithInvite(context.Background(), "newuser", "hash", 4, code) if err != nil { t.Fatalf("CreateUserWithInvite: %v", err) } @@ -660,7 +661,7 @@ func TestCreateUserWithInvite_Success(t *testing.T) { } // Verify invite use count incremented. - inv, _ := database.GetInvite(code) + inv, _ := database.GetInvite(context.Background(), code) if inv == nil || inv.Uses != 1 { t.Errorf("invite uses = %v, want 1", inv) } @@ -669,7 +670,7 @@ func TestCreateUserWithInvite_Success(t *testing.T) { func TestCreateUserWithInvite_InvalidCode(t *testing.T) { database := openMigratedMemory(t) - _, err := database.CreateUserWithInvite("baduser", "hash", 4, "nonexistent-code") + _, err := database.CreateUserWithInvite(context.Background(), "baduser", "hash", 4, "nonexistent-code") if err == nil { t.Error("expected error for invalid invite code") } @@ -677,12 +678,12 @@ func TestCreateUserWithInvite_InvalidCode(t *testing.T) { func TestCreateUserWithInvite_RevokedInvite(t *testing.T) { database := openMigratedMemory(t) - creatorID, _ := database.CreateUser("inv-revoke-creator", "hash", 2) + creatorID, _ := database.CreateUser(context.Background(), "inv-revoke-creator", "hash", 2) - code, _ := database.CreateInvite(creatorID, 0, nil) - _ = database.RevokeInvite(code) + code, _ := database.CreateInvite(context.Background(), creatorID, 0, nil) + _ = database.RevokeInvite(context.Background(), code) - _, err := database.CreateUserWithInvite("revokeduser", "hash", 4, code) + _, err := database.CreateUserWithInvite(context.Background(), "revokeduser", "hash", 4, code) if err == nil { t.Error("expected error for revoked invite") } @@ -690,13 +691,13 @@ func TestCreateUserWithInvite_RevokedInvite(t *testing.T) { func TestCreateUserWithInvite_ExpiredInvite(t *testing.T) { database := openMigratedMemory(t) - creatorID, _ := database.CreateUser("inv-expire-creator", "hash", 2) + creatorID, _ := database.CreateUser(context.Background(), "inv-expire-creator", "hash", 2) // Create an invite that expires in the past. pastTime := time.Now().Add(-1 * time.Hour) - code, _ := database.CreateInvite(creatorID, 0, &pastTime) + code, _ := database.CreateInvite(context.Background(), creatorID, 0, &pastTime) - _, err := database.CreateUserWithInvite("expireduser", "hash", 4, code) + _, err := database.CreateUserWithInvite(context.Background(), "expireduser", "hash", 4, code) if err == nil { t.Error("expected error for expired invite") } @@ -707,7 +708,7 @@ func TestCreateUserWithInvite_ExpiredInvite(t *testing.T) { func TestListInvites_DB_Empty(t *testing.T) { database := openMigratedMemory(t) - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites: %v", err) } @@ -718,12 +719,12 @@ func TestListInvites_DB_Empty(t *testing.T) { func TestListInvites_DB_ReturnsAll(t *testing.T) { database := openMigratedMemory(t) - creatorID, _ := database.CreateUser("list-inv-creator", "hash", 2) + creatorID, _ := database.CreateUser(context.Background(), "list-inv-creator", "hash", 2) - _, _ = database.CreateInvite(creatorID, 5, nil) - _, _ = database.CreateInvite(creatorID, 0, nil) + _, _ = database.CreateInvite(context.Background(), creatorID, 5, nil) + _, _ = database.CreateInvite(context.Background(), creatorID, 0, nil) - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites: %v", err) } @@ -735,7 +736,7 @@ func TestListInvites_DB_ReturnsAll(t *testing.T) { func TestUseInviteAtomic_NonExistent(t *testing.T) { database := openMigratedMemory(t) - err := database.UseInviteAtomic("does-not-exist") + err := database.UseInviteAtomic(context.Background(), "does-not-exist") if err == nil { t.Error("expected error for non-existent invite") } @@ -746,7 +747,7 @@ func TestUseInviteAtomic_NonExistent(t *testing.T) { func TestSearchMessages_EmptyQuery(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessages("", nil, 10) + results, err := database.SearchMessages(context.Background(), "", nil, 10) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -758,7 +759,7 @@ func TestSearchMessages_EmptyQuery(t *testing.T) { func TestSearchMessages_ZeroLimit(t *testing.T) { database := openMigratedMemory(t) - results, err := database.SearchMessages("test", nil, 0) + results, err := database.SearchMessages(context.Background(), "test", nil, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -772,10 +773,10 @@ func TestSearchMessages_SpecialCharsStripped(t *testing.T) { userID := seedUser(t, database, "srch-special") chID := seedChannel(t, database, "srch-special-ch") - _, _ = database.CreateMessage(chID, userID, "hello world content", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hello world content", nil) // FTS special chars should be stripped, leaving a valid query. - results, err := database.SearchMessages("hello* \"world\"", nil, 10) + results, err := database.SearchMessages(context.Background(), "hello* \"world\"", nil, 10) if err != nil { t.Fatalf("SearchMessages with special chars: %v", err) } diff --git a/Server/db/db.go b/Server/db/db.go index 9425dd7b..293e2f82 100644 --- a/Server/db/db.go +++ b/Server/db/db.go @@ -25,11 +25,6 @@ type DB struct { q *dbgen.Queries } -// dbCtx is the context used for delegated dbgen calls. The public db.DB API is -// context-free today; callers that need cancellation use the *Context helpers -// directly. Using Background here preserves the existing behavior exactly. -func dbCtx() context.Context { return context.Background() } - // Open opens (or creates) a SQLite database at path, enables WAL mode and // foreign key enforcement, and returns a ready-to-use DB. func Open(path string) (*DB, error) { @@ -104,41 +99,21 @@ func (d *DB) Close() error { return d.sqlDB.Close() } -// QueryRow executes a query that returns at most one row. -func (d *DB) QueryRow(query string, args ...any) *sql.Row { - return d.sqlDB.QueryRow(query, args...) -} - // QueryRowContext executes a query that returns at most one row, with context. func (d *DB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { return d.sqlDB.QueryRowContext(ctx, query, args...) } -// Exec executes a query that doesn't return rows. -func (d *DB) Exec(query string, args ...any) (sql.Result, error) { - return d.sqlDB.Exec(query, args...) -} - // ExecContext executes a query that doesn't return rows, with context. func (d *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { return d.sqlDB.ExecContext(ctx, query, args...) } -// Query executes a query that returns multiple rows. -func (d *DB) Query(query string, args ...any) (*sql.Rows, error) { - return d.sqlDB.Query(query, args...) -} - // QueryContext executes a query that returns multiple rows, with context. func (d *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { return d.sqlDB.QueryContext(ctx, query, args...) } -// Begin starts a database transaction. -func (d *DB) Begin() (*sql.Tx, error) { - return d.sqlDB.Begin() -} - // BeginTx starts a database transaction with context and options. func (d *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { return d.sqlDB.BeginTx(ctx, opts) diff --git a/Server/db/db_test.go b/Server/db/db_test.go index fc491e32..16dbdaab 100644 --- a/Server/db/db_test.go +++ b/Server/db/db_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "database/sql" "fmt" "io" @@ -59,7 +60,7 @@ func TestWALModeEnabled(t *testing.T) { database := openMemory(t) var journalMode string - err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode) + err := database.QueryRowContext(context.Background(), "PRAGMA journal_mode;").Scan(&journalMode) if err != nil { t.Fatalf("PRAGMA journal_mode query error: %v", err) } @@ -82,7 +83,7 @@ func TestWALModeEnabledOnFile(t *testing.T) { defer database.Close() //nolint:errcheck var journalMode string - if err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode); err != nil { + if err := database.QueryRowContext(context.Background(), "PRAGMA journal_mode;").Scan(&journalMode); err != nil { t.Fatalf("PRAGMA journal_mode query error: %v", err) } if journalMode != "wal" { @@ -94,7 +95,7 @@ func TestForeignKeysEnabled(t *testing.T) { database := openMemory(t) var fkEnabled int - if err := database.QueryRow("PRAGMA foreign_keys;").Scan(&fkEnabled); err != nil { + if err := database.QueryRowContext(context.Background(), "PRAGMA foreign_keys;").Scan(&fkEnabled); err != nil { t.Fatalf("PRAGMA foreign_keys query error: %v", err) } if fkEnabled != 1 { @@ -118,7 +119,7 @@ func TestMigrateCreatesAllTables(t *testing.T) { for _, table := range expectedTables { t.Run(table, func(t *testing.T) { var name string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table, ).Scan(&name) @@ -139,7 +140,7 @@ func TestMigrateCreatesFTSTable(t *testing.T) { } var name string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'", ).Scan(&name) if err == sql.ErrNoRows { @@ -169,7 +170,7 @@ func TestMigrateInsertsDefaultRoles(t *testing.T) { } var count int - if err := database.QueryRow("SELECT COUNT(*) FROM roles").Scan(&count); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM roles").Scan(&count); err != nil { t.Fatalf("COUNT roles error: %v", err) } if count < 4 { @@ -185,7 +186,7 @@ func TestMigrateInsertsDefaultSettings(t *testing.T) { } var value string - err := database.QueryRow("SELECT value FROM settings WHERE key='registration_open'").Scan(&value) + err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='registration_open'").Scan(&value) if err != nil { t.Fatalf("settings query error: %v", err) } @@ -211,7 +212,7 @@ func TestMigrateCreatesIndexes(t *testing.T) { for _, idx := range expectedIndexes { t.Run(idx, func(t *testing.T) { var name string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='index' AND name=?", idx, ).Scan(&name) @@ -244,7 +245,7 @@ func TestQueryRow(t *testing.T) { // Verify we can run a simple query via the exposed DB. var schemaVersion string - err := database.QueryRow("SELECT value FROM settings WHERE key='schema_version'").Scan(&schemaVersion) + err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='schema_version'").Scan(&schemaVersion) if err != nil { t.Fatalf("QueryRow error: %v", err) } @@ -261,13 +262,13 @@ func TestExec(t *testing.T) { } // Insert a settings row using Exec. - _, err := database.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", "test_key", "test_val") + _, err := database.ExecContext(context.Background(), "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", "test_key", "test_val") if err != nil { t.Fatalf("Exec() error: %v", err) } var val string - if err := database.QueryRow("SELECT value FROM settings WHERE key='test_key'").Scan(&val); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='test_key'").Scan(&val); err != nil { t.Fatalf("QueryRow after Exec error: %v", err) } if val != "test_val" { @@ -282,7 +283,7 @@ func TestQuery(t *testing.T) { t.Fatalf("Migrate() error: %v", err) } - rows, err := database.Query("SELECT key FROM settings") + rows, err := database.QueryContext(context.Background(), "SELECT key FROM settings") if err != nil { t.Fatalf("Query() error: %v", err) } @@ -308,7 +309,7 @@ func TestBegin(t *testing.T) { t.Fatalf("Migrate() error: %v", err) } - tx, err := database.Begin() + tx, err := database.BeginTx(context.Background(), nil) if err != nil { t.Fatalf("Begin() error: %v", err) } @@ -325,7 +326,7 @@ func TestBegin(t *testing.T) { // After rollback, tx_key should not exist. var val string - err = database.QueryRow("SELECT value FROM settings WHERE key='tx_key'").Scan(&val) + err = database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='tx_key'").Scan(&val) if err == nil { t.Error("tx_key should not exist after rollback") } @@ -433,7 +434,7 @@ func TestMigrateFSSkipsNonSQL(t *testing.T) { // The table from the .sql file should exist. var name string - if err := database.QueryRow( + if err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='test_skip'", ).Scan(&name); err != nil { t.Error("table test_skip not found after MigrateFS") @@ -465,7 +466,7 @@ func TestMigrateWALAndFKOnFile(t *testing.T) { // Tables should exist. var name string - if err := database.QueryRow( + if err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", ).Scan(&name); err != nil { t.Errorf("users table not found after migration on file db: %v", err) diff --git a/Server/db/dm_queries.go b/Server/db/dm_queries.go index f1700f7d..207e97f9 100644 --- a/Server/db/dm_queries.go +++ b/Server/db/dm_queries.go @@ -36,8 +36,8 @@ type DMUser struct { // The entire lookup+create is wrapped in a single IMMEDIATE transaction to // prevent a TOCTOU race where two concurrent requests both see ErrNoRows and // each create a separate DM channel for the same user pair. -func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error) { - tx, err := d.sqlDB.BeginTx(context.Background(), &sql.TxOptions{ +func (d *DB) GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) (*Channel, bool, error) { + tx, err := d.sqlDB.BeginTx(ctx, &sql.TxOptions{ Isolation: sql.LevelSerializable, }) if err != nil { @@ -66,7 +66,7 @@ func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error if commitErr := tx.Commit(); commitErr != nil { return nil, false, fmt.Errorf("GetOrCreateDMChannel commit existing: %w", commitErr) } - ch, getErr := d.GetChannel(existingID) + ch, getErr := d.GetChannel(ctx, existingID) if getErr != nil { return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch existing: %w", getErr) } @@ -120,7 +120,7 @@ func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error return nil, false, fmt.Errorf("GetOrCreateDMChannel commit: %w", err) } - ch, err := d.GetChannel(channelID) + ch, err := d.GetChannel(ctx, channelID) if err != nil { return nil, false, fmt.Errorf("GetOrCreateDMChannel fetch new: %w", err) } @@ -136,8 +136,8 @@ func (d *DB) GetOrCreateDMChannel(user1ID, user2ID int64) (*Channel, bool, error // (dm_open_state only contains rows for DM channels), and the explicit // "c.type = 'dm'" predicate in the JOIN provides a defensive second check. // No additional channel-type validation is needed at the Go layer. -func (d *DB) GetUserDMChannels(userID int64) ([]DMChannelInfo, error) { - rows, err := d.sqlDB.Query( +func (d *DB) GetUserDMChannels(ctx context.Context, userID int64) ([]DMChannelInfo, error) { + rows, err := d.sqlDB.QueryContext(ctx, `SELECT c.id AS channel_id, u.id AS recipient_id, @@ -203,8 +203,8 @@ func (d *DB) GetUserDMChannels(userID int64) ([]DMChannelInfo, error) { // ─── OpenDM / CloseDM ────────────────────────────────────────────────────── // OpenDM adds a DM channel to a user's open list (idempotent). -func (d *DB) OpenDM(userID, channelID int64) error { - if err := d.q.OpenDM(dbCtx(), dbgen.OpenDMParams{ +func (d *DB) OpenDM(ctx context.Context, userID, channelID int64) error { + if err := d.q.OpenDM(ctx, dbgen.OpenDMParams{ UserID: userID, ChannelID: channelID, }); err != nil { @@ -214,8 +214,8 @@ func (d *DB) OpenDM(userID, channelID int64) error { } // CloseDM removes a DM channel from a user's open list. -func (d *DB) CloseDM(userID, channelID int64) error { - if err := d.q.CloseDM(dbCtx(), dbgen.CloseDMParams{ +func (d *DB) CloseDM(ctx context.Context, userID, channelID int64) error { + if err := d.q.CloseDM(ctx, dbgen.CloseDMParams{ UserID: userID, ChannelID: channelID, }); err != nil { @@ -227,8 +227,8 @@ func (d *DB) CloseDM(userID, channelID int64) error { // ─── Participant helpers ──────────────────────────────────────────────────── // IsDMParticipant checks if a user is a participant in a DM channel. -func (d *DB) IsDMParticipant(userID, channelID int64) (bool, error) { - _, err := d.q.IsDMParticipant(dbCtx(), dbgen.IsDMParticipantParams{ +func (d *DB) IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) { + _, err := d.q.IsDMParticipant(ctx, dbgen.IsDMParticipantParams{ UserID: userID, ChannelID: channelID, }) @@ -242,8 +242,8 @@ func (d *DB) IsDMParticipant(userID, channelID int64) (bool, error) { } // GetDMParticipantIDs returns all participant user IDs for a DM channel. -func (d *DB) GetDMParticipantIDs(channelID int64) ([]int64, error) { - ids, err := d.q.GetDMParticipantIDs(dbCtx(), channelID) +func (d *DB) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) { + ids, err := d.q.GetDMParticipantIDs(ctx, channelID) if err != nil { return nil, fmt.Errorf("GetDMParticipantIDs: %w", err) } @@ -251,9 +251,9 @@ func (d *DB) GetDMParticipantIDs(channelID int64) ([]int64, error) { } // GetDMRecipient returns the other participant in a DM channel. -func (d *DB) GetDMRecipient(channelID, requestingUserID int64) (*User, error) { +func (d *DB) GetDMRecipient(ctx context.Context, channelID, requestingUserID int64) (*User, error) { var recipientID int64 - err := d.sqlDB.QueryRow( + err := d.sqlDB.QueryRowContext(ctx, `SELECT user_id FROM dm_participants WHERE channel_id = ? AND user_id != ? LIMIT 1`, @@ -265,5 +265,5 @@ func (d *DB) GetDMRecipient(channelID, requestingUserID int64) (*User, error) { if err != nil { return nil, fmt.Errorf("GetDMRecipient lookup: %w", err) } - return d.GetUserByID(recipientID) + return d.GetUserByID(ctx, recipientID) } diff --git a/Server/db/dm_queries_test.go b/Server/db/dm_queries_test.go index d5b6e886..c486dfdc 100644 --- a/Server/db/dm_queries_test.go +++ b/Server/db/dm_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" ) @@ -11,7 +12,7 @@ func TestGetOrCreateDMChannel_CreatesNew(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, created, err := database.GetOrCreateDMChannel(user1, user2) + ch, created, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } @@ -34,7 +35,7 @@ func TestGetOrCreateDMChannel_Idempotent(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch1, created1, err := database.GetOrCreateDMChannel(user1, user2) + ch1, created1, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("first GetOrCreateDMChannel: %v", err) } @@ -42,7 +43,7 @@ func TestGetOrCreateDMChannel_Idempotent(t *testing.T) { t.Error("expected created=true on first call") } - ch2, created2, err := database.GetOrCreateDMChannel(user1, user2) + ch2, created2, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("second GetOrCreateDMChannel: %v", err) } @@ -59,13 +60,13 @@ func TestGetOrCreateDMChannel_IdempotentReversedOrder(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch1, _, err := database.GetOrCreateDMChannel(user1, user2) + ch1, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel(u1,u2): %v", err) } // Reversed argument order should find the same channel. - ch2, created, err := database.GetOrCreateDMChannel(user2, user1) + ch2, created, err := database.GetOrCreateDMChannel(context.Background(), user2, user1) if err != nil { t.Fatalf("GetOrCreateDMChannel(u2,u1): %v", err) } @@ -82,18 +83,18 @@ func TestGetOrCreateDMChannel_ReopensForCaller(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // Close the DM for user1. - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("CloseDM: %v", err) } // Verify user1 no longer sees it. - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -102,7 +103,7 @@ func TestGetOrCreateDMChannel_ReopensForCaller(t *testing.T) { } // Call GetOrCreateDMChannel again — should re-open for user1. - ch2, created, err := database.GetOrCreateDMChannel(user1, user2) + ch2, created, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel after close: %v", err) } @@ -114,7 +115,7 @@ func TestGetOrCreateDMChannel_ReopensForCaller(t *testing.T) { } // User1 should now see the DM again. - dms, err = database.GetUserDMChannels(user1) + dms, err = database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels after reopen: %v", err) } @@ -129,7 +130,7 @@ func TestGetUserDMChannels_EmptyList(t *testing.T) { database := openMigratedMemory(t) user1 := seedUser(t, database, "alice") - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -144,16 +145,16 @@ func TestGetUserDMChannels_ReturnsOpenDMs(t *testing.T) { user2 := seedUser(t, database, "bob") user3 := seedUser(t, database, "charlie") - _, _, err := database.GetOrCreateDMChannel(user1, user2) + _, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel(u1,u2): %v", err) } - _, _, err = database.GetOrCreateDMChannel(user1, user3) + _, _, err = database.GetOrCreateDMChannel(context.Background(), user1, user3) if err != nil { t.Fatalf("GetOrCreateDMChannel(u1,u3): %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -179,16 +180,16 @@ func TestGetUserDMChannels_ExcludesClosedDMs(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("CloseDM: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -197,7 +198,7 @@ func TestGetUserDMChannels_ExcludesClosedDMs(t *testing.T) { } // User2 should still see the DM (only user1 closed it). - dms2, err := database.GetUserDMChannels(user2) + dms2, err := database.GetUserDMChannels(context.Background(), user2) if err != nil { t.Fatalf("GetUserDMChannels(user2): %v", err) } @@ -211,31 +212,31 @@ func TestGetUserDMChannels_UnreadCount(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // Send 3 messages from user2 in the DM channel. - msg1, err := database.CreateMessage(ch.ID, user2, "hello", nil) + msg1, err := database.CreateMessage(context.Background(), ch.ID, user2, "hello", nil) if err != nil { t.Fatalf("CreateMessage 1: %v", err) } - _, err = database.CreateMessage(ch.ID, user2, "how are you", nil) + _, err = database.CreateMessage(context.Background(), ch.ID, user2, "how are you", nil) if err != nil { t.Fatalf("CreateMessage 2: %v", err) } - _, err = database.CreateMessage(ch.ID, user2, "anyone there?", nil) + _, err = database.CreateMessage(context.Background(), ch.ID, user2, "anyone there?", nil) if err != nil { t.Fatalf("CreateMessage 3: %v", err) } // Mark user1 as having read only the first message. - if err := database.UpdateReadState(user1, ch.ID, msg1); err != nil { + if err := database.UpdateReadState(context.Background(), user1, ch.ID, msg1); err != nil { t.Fatalf("UpdateReadState: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -252,12 +253,12 @@ func TestGetUserDMChannels_NoMessages(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - _, _, err := database.GetOrCreateDMChannel(user1, user2) + _, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -277,21 +278,21 @@ func TestGetUserDMChannels_LastMessagePreview(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - _, err = database.CreateMessage(ch.ID, user2, "first message", nil) + _, err = database.CreateMessage(context.Background(), ch.ID, user2, "first message", nil) if err != nil { t.Fatalf("CreateMessage 1: %v", err) } - lastMsgID, err := database.CreateMessage(ch.ID, user2, "latest message", nil) + lastMsgID, err := database.CreateMessage(context.Background(), ch.ID, user2, "latest message", nil) if err != nil { t.Fatalf("CreateMessage 2: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -316,12 +317,12 @@ func TestIsDMParticipant_ValidParticipant(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - ok, err := database.IsDMParticipant(user1, ch.ID) + ok, err := database.IsDMParticipant(context.Background(), user1, ch.ID) if err != nil { t.Fatalf("IsDMParticipant(user1): %v", err) } @@ -329,7 +330,7 @@ func TestIsDMParticipant_ValidParticipant(t *testing.T) { t.Error("expected true for user1") } - ok, err = database.IsDMParticipant(user2, ch.ID) + ok, err = database.IsDMParticipant(context.Background(), user2, ch.ID) if err != nil { t.Fatalf("IsDMParticipant(user2): %v", err) } @@ -344,12 +345,12 @@ func TestIsDMParticipant_NonParticipant(t *testing.T) { user2 := seedUser(t, database, "bob") user3 := seedUser(t, database, "charlie") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - ok, err := database.IsDMParticipant(user3, ch.ID) + ok, err := database.IsDMParticipant(context.Background(), user3, ch.ID) if err != nil { t.Fatalf("IsDMParticipant(user3): %v", err) } @@ -362,7 +363,7 @@ func TestIsDMParticipant_NonExistentChannel(t *testing.T) { database := openMigratedMemory(t) user1 := seedUser(t, database, "alice") - ok, err := database.IsDMParticipant(user1, 99999) + ok, err := database.IsDMParticipant(context.Background(), user1, 99999) if err != nil { t.Fatalf("IsDMParticipant: %v", err) } @@ -378,18 +379,18 @@ func TestOpenDM_Idempotent(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // Already open from creation — opening again should not error. - if err := database.OpenDM(user1, ch.ID); err != nil { + if err := database.OpenDM(context.Background(), user1, ch.ID); err != nil { t.Errorf("OpenDM (idempotent) error: %v", err) } // Should still have exactly 1 DM. - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -403,16 +404,16 @@ func TestCloseDM_RemovesFromOpenList(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("CloseDM: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -426,16 +427,16 @@ func TestCloseDM_Idempotent(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // Close twice — should not error. - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Errorf("first CloseDM error: %v", err) } - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Errorf("second CloseDM (idempotent) error: %v", err) } } @@ -445,20 +446,20 @@ func TestOpenDM_AfterClose(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - if err := database.CloseDM(user1, ch.ID); err != nil { + if err := database.CloseDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("CloseDM: %v", err) } - if err := database.OpenDM(user1, ch.ID); err != nil { + if err := database.OpenDM(context.Background(), user1, ch.ID); err != nil { t.Fatalf("OpenDM after close: %v", err) } - dms, err := database.GetUserDMChannels(user1) + dms, err := database.GetUserDMChannels(context.Background(), user1) if err != nil { t.Fatalf("GetUserDMChannels: %v", err) } @@ -474,12 +475,12 @@ func TestGetDMParticipantIDs_ReturnsBoth(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } - ids, err := database.GetDMParticipantIDs(ch.ID) + ids, err := database.GetDMParticipantIDs(context.Background(), ch.ID) if err != nil { t.Fatalf("GetDMParticipantIDs: %v", err) } @@ -499,7 +500,7 @@ func TestGetDMParticipantIDs_ReturnsBoth(t *testing.T) { func TestGetDMParticipantIDs_NonExistentChannel(t *testing.T) { database := openMigratedMemory(t) - ids, err := database.GetDMParticipantIDs(99999) + ids, err := database.GetDMParticipantIDs(context.Background(), 99999) if err != nil { t.Fatalf("GetDMParticipantIDs: %v", err) } @@ -515,13 +516,13 @@ func TestGetDMRecipient_ReturnsOtherUser(t *testing.T) { user1 := seedUser(t, database, "alice") user2 := seedUser(t, database, "bob") - ch, _, err := database.GetOrCreateDMChannel(user1, user2) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1, user2) if err != nil { t.Fatalf("GetOrCreateDMChannel: %v", err) } // From user1's perspective, recipient should be user2. - recipient, err := database.GetDMRecipient(ch.ID, user1) + recipient, err := database.GetDMRecipient(context.Background(), ch.ID, user1) if err != nil { t.Fatalf("GetDMRecipient(user1): %v", err) } @@ -536,7 +537,7 @@ func TestGetDMRecipient_ReturnsOtherUser(t *testing.T) { } // From user2's perspective, recipient should be user1. - recipient2, err := database.GetDMRecipient(ch.ID, user2) + recipient2, err := database.GetDMRecipient(context.Background(), ch.ID, user2) if err != nil { t.Fatalf("GetDMRecipient(user2): %v", err) } @@ -555,7 +556,7 @@ func TestGetDMRecipient_NonExistentChannel(t *testing.T) { database := openMigratedMemory(t) user1 := seedUser(t, database, "alice") - recipient, err := database.GetDMRecipient(99999, user1) + recipient, err := database.GetDMRecipient(context.Background(), 99999, user1) if err != nil { t.Fatalf("GetDMRecipient: %v", err) } diff --git a/Server/db/invite_queries.go b/Server/db/invite_queries.go index 800ee8da..ab28f1a0 100644 --- a/Server/db/invite_queries.go +++ b/Server/db/invite_queries.go @@ -1,11 +1,14 @@ package db -import "fmt" +import ( + "context" + "fmt" +) // ListInvites returns invites ordered by creation time descending. // M-12: Limited to 200 rows to prevent unbounded result sets. -func (d *DB) ListInvites() ([]*Invite, error) { - rows, err := d.q.ListInvites(dbCtx()) +func (d *DB) ListInvites(ctx context.Context) ([]*Invite, error) { + rows, err := d.q.ListInvites(ctx) if err != nil { return nil, fmt.Errorf("ListInvites: %w", err) } diff --git a/Server/db/lockout_queries.go b/Server/db/lockout_queries.go index 80ac5f33..7b700978 100644 --- a/Server/db/lockout_queries.go +++ b/Server/db/lockout_queries.go @@ -1,14 +1,15 @@ package db import ( + "context" "time" "github.com/owncord/server/db/dbgen" ) // UpsertLockout inserts or replaces a rate-limit lockout entry. -func (d *DB) UpsertLockout(key string, expiresAt time.Time) error { - return d.q.UpsertLockout(dbCtx(), dbgen.UpsertLockoutParams{ +func (d *DB) UpsertLockout(ctx context.Context, key string, expiresAt time.Time) error { + return d.q.UpsertLockout(ctx, dbgen.UpsertLockoutParams{ Key: key, ExpiresAt: expiresAt.UTC().Format(time.RFC3339), }) @@ -16,8 +17,8 @@ func (d *DB) UpsertLockout(key string, expiresAt time.Time) error { // LoadActiveLockouts returns all lockouts that have not yet expired as // parallel slices of keys and expiry times. -func (d *DB) LoadActiveLockouts() (keys []string, expiresAt []time.Time, err error) { - rows, err := d.q.LoadActiveLockouts(dbCtx(), time.Now().UTC().Format(time.RFC3339)) +func (d *DB) LoadActiveLockouts(ctx context.Context) (keys []string, expiresAt []time.Time, err error) { + rows, err := d.q.LoadActiveLockouts(ctx, time.Now().UTC().Format(time.RFC3339)) if err != nil { return nil, nil, err } @@ -33,11 +34,11 @@ func (d *DB) LoadActiveLockouts() (keys []string, expiresAt []time.Time, err err } // CleanupExpiredLockouts removes lockout rows whose expiry has passed. -func (d *DB) CleanupExpiredLockouts() error { - return d.q.CleanupExpiredLockouts(dbCtx(), time.Now().UTC().Format(time.RFC3339)) +func (d *DB) CleanupExpiredLockouts(ctx context.Context) error { + return d.q.CleanupExpiredLockouts(ctx, time.Now().UTC().Format(time.RFC3339)) } // DeleteLockout removes a single lockout entry. -func (d *DB) DeleteLockout(key string) error { - return d.q.DeleteLockout(dbCtx(), key) +func (d *DB) DeleteLockout(ctx context.Context, key string) error { + return d.q.DeleteLockout(ctx, key) } diff --git a/Server/db/message_queries.go b/Server/db/message_queries.go index a6c99037..f0c0e7f8 100644 --- a/Server/db/message_queries.go +++ b/Server/db/message_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -46,8 +47,8 @@ func sanitizeFTSQuery(q string) string { // CreateMessage inserts a new message and returns the assigned ID. // Content should already be sanitized before calling this function. -func (d *DB) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { - res, err := d.q.CreateMessage(dbCtx(), dbgen.CreateMessageParams{ +func (d *DB) CreateMessage(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (int64, error) { + res, err := d.q.CreateMessage(ctx, dbgen.CreateMessageParams{ ChannelID: channelID, UserID: userID, Content: content, @@ -61,8 +62,8 @@ func (d *DB) CreateMessage(channelID, userID int64, content string, replyTo *int // GetMessage returns the message with the given ID, or nil if not found. // Soft-deleted messages are returned so callers can broadcast the deletion event. -func (d *DB) GetMessage(id int64) (*Message, error) { - m, err := d.q.GetMessage(dbCtx(), id) +func (d *DB) GetMessage(ctx context.Context, id int64) (*Message, error) { + m, err := d.q.GetMessage(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -74,13 +75,13 @@ func (d *DB) GetMessage(id int64) (*Message, error) { // GetMessages returns up to limit messages in a channel, ordered newest-first. // When before > 0 only messages with id < before are returned (pagination). -func (d *DB) GetMessages(channelID, before int64, limit int) ([]MessageWithUser, error) { +func (d *DB) GetMessages(ctx context.Context, channelID, before int64, limit int) ([]MessageWithUser, error) { var ( rows *sql.Rows err error ) if before > 0 { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, u.username, u.avatar @@ -90,7 +91,7 @@ func (d *DB) GetMessages(channelID, before int64, limit int) ([]MessageWithUser, channelID, before, limit, ) } else { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp, u.username, u.avatar @@ -124,8 +125,8 @@ func (d *DB) GetMessages(channelID, before int64, limit int) ([]MessageWithUser, // EditMessage updates the content and sets edited_at on the message. // Returns an error if the message does not exist or userID does not match the owner. -func (d *DB) EditMessage(id, userID int64, content string) error { - msg, err := d.GetMessage(id) +func (d *DB) EditMessage(ctx context.Context, id, userID int64, content string) error { + msg, err := d.GetMessage(ctx, id) if err != nil { return err } @@ -136,7 +137,7 @@ func (d *DB) EditMessage(id, userID int64, content string) error { return fmt.Errorf("EditMessage: user %d does not own message %d: %w", userID, id, ErrForbidden) } - if err := d.q.EditMessageContent(dbCtx(), dbgen.EditMessageContentParams{ + if err := d.q.EditMessageContent(ctx, dbgen.EditMessageContentParams{ Content: content, ID: id, }); err != nil { @@ -147,8 +148,8 @@ func (d *DB) EditMessage(id, userID int64, content string) error { // DeleteMessage performs a soft delete (sets deleted=1) on the message. // The calling user must be the message owner or ismod must be true. -func (d *DB) DeleteMessage(id, userID int64, ismod bool) error { - msg, err := d.GetMessage(id) +func (d *DB) DeleteMessage(ctx context.Context, id, userID int64, ismod bool) error { + msg, err := d.GetMessage(ctx, id) if err != nil { return err } @@ -159,15 +160,15 @@ func (d *DB) DeleteMessage(id, userID int64, ismod bool) error { return fmt.Errorf("DeleteMessage: user %d does not own message %d: %w", userID, id, ErrForbidden) } - if err := d.q.SoftDeleteMessage(dbCtx(), id); err != nil { + if err := d.q.SoftDeleteMessage(ctx, id); err != nil { return fmt.Errorf("DeleteMessage: %w", err) } return nil } // AddReaction inserts a reaction. Returns an error on duplicate (same user+emoji+message). -func (d *DB) AddReaction(messageID, userID int64, emoji string) error { - if err := d.q.AddReaction(dbCtx(), dbgen.AddReactionParams{ +func (d *DB) AddReaction(ctx context.Context, messageID, userID int64, emoji string) error { + if err := d.q.AddReaction(ctx, dbgen.AddReactionParams{ MessageID: messageID, UserID: userID, Emoji: emoji, @@ -178,8 +179,8 @@ func (d *DB) AddReaction(messageID, userID int64, emoji string) error { } // RemoveReaction deletes a reaction. Returns an error if it does not exist. -func (d *DB) RemoveReaction(messageID, userID int64, emoji string) error { - res, err := d.q.RemoveReaction(dbCtx(), dbgen.RemoveReactionParams{ +func (d *DB) RemoveReaction(ctx context.Context, messageID, userID int64, emoji string) error { + res, err := d.q.RemoveReaction(ctx, dbgen.RemoveReactionParams{ MessageID: messageID, UserID: userID, Emoji: emoji, @@ -196,8 +197,8 @@ func (d *DB) RemoveReaction(messageID, userID int64, emoji string) error { // GetReactions returns aggregated reaction counts for a message. // MeReacted is always false here (caller passes requesting userID if needed). -func (d *DB) GetReactions(messageID int64) ([]ReactionCount, error) { - rows, err := d.q.GetReactionCounts(dbCtx(), messageID) +func (d *DB) GetReactions(ctx context.Context, messageID int64) ([]ReactionCount, error) { + rows, err := d.q.GetReactionCounts(ctx, messageID) if err != nil { return nil, fmt.Errorf("GetReactions: %w", err) } @@ -211,7 +212,7 @@ func (d *DB) GetReactions(messageID int64) ([]ReactionCount, error) { // SearchMessages performs a full-text search against the messages_fts virtual table. // When channelID is non-nil the search is scoped to that channel. // Deleted messages are excluded from results. -func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]MessageSearchResult, error) { +func (d *DB) SearchMessages(ctx context.Context, query string, channelID *int64, limit int) ([]MessageSearchResult, error) { if query == "" { return []MessageSearchResult{}, nil } @@ -229,7 +230,7 @@ func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]Messag ) if channelID != nil { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f JOIN messages m ON f.rowid = m.id @@ -240,7 +241,7 @@ func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]Messag query, *channelID, limit, ) } else { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f JOIN messages m ON f.rowid = m.id @@ -278,7 +279,7 @@ func (d *DB) SearchMessages(query string, channelID *int64, limit int) ([]Messag // SearchMessagesInChannels performs a full-text search scoped to the given // channel IDs. This prevents information leakage by filtering at the DB level // rather than post-filtering in application code. -func (d *DB) SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]MessageSearchResult, error) { +func (d *DB) SearchMessagesInChannels(ctx context.Context, query string, channelIDs []int64, limit int) ([]MessageSearchResult, error) { if query == "" || len(channelIDs) == 0 { return []MessageSearchResult{}, nil } @@ -300,7 +301,7 @@ func (d *DB) SearchMessagesInChannels(query string, channelIDs []int64, limit in } args = append(args, limit) - rows, err := d.sqlDB.Query( + rows, err := d.sqlDB.QueryContext(ctx, fmt.Sprintf( `SELECT m.id, m.channel_id, c.name, u.id, u.username, u.avatar, m.content, m.timestamp FROM messages_fts f @@ -338,13 +339,13 @@ func (d *DB) SearchMessagesInChannels(query string, channelIDs []int64, limit in // GetMessagesForAPI returns messages in the API.md response shape, including // user object, reactions (with me flag), and attachments. -func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]MessageAPIResponse, error) { +func (d *DB) GetMessagesForAPI(ctx context.Context, channelID, before int64, limit int, requestingUserID int64) ([]MessageAPIResponse, error) { var ( rows *sql.Rows err error ) if before > 0 { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id @@ -353,7 +354,7 @@ func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUse channelID, before, limit, ) } else { - rows, err = d.sqlDB.Query( + rows, err = d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id @@ -367,11 +368,11 @@ func (d *DB) GetMessagesForAPI(channelID, before int64, limit int, requestingUse } defer rows.Close() //nolint:errcheck - return d.scanAndEnrichMessages(rows, requestingUserID) + return d.scanAndEnrichMessages(ctx, rows, requestingUserID) } // getReactionsBatch returns aggregated reactions for multiple messages. -func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int64][]ReactionInfo, error) { +func (d *DB) getReactionsBatch(ctx context.Context, msgIDs []int64, requestingUserID int64) (map[int64][]ReactionInfo, error) { if len(msgIDs) == 0 { return map[int64][]ReactionInfo{}, nil } @@ -399,7 +400,7 @@ func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int6 ) args = append([]any{requestingUserID}, args...) - rows, err := d.sqlDB.Query(query, args...) + rows, err := d.sqlDB.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("getReactionsBatch: %w", err) } @@ -423,8 +424,8 @@ func (d *DB) getReactionsBatch(msgIDs []int64, requestingUserID int64) (map[int6 } // UpdateReadState upserts the read state for a user in a channel. -func (d *DB) UpdateReadState(userID, channelID, lastReadMessageID int64) error { - if err := d.q.UpdateReadState(dbCtx(), dbgen.UpdateReadStateParams{ +func (d *DB) UpdateReadState(ctx context.Context, userID, channelID, lastReadMessageID int64) error { + if err := d.q.UpdateReadState(ctx, dbgen.UpdateReadStateParams{ UserID: userID, ChannelID: channelID, LastMessageID: lastReadMessageID, @@ -436,8 +437,8 @@ func (d *DB) UpdateReadState(userID, channelID, lastReadMessageID int64) error { // GetChannelUnreadCounts returns per-channel unread counts and last message IDs // for a given user. Only text channels with at least one message are included. -func (d *DB) GetChannelUnreadCounts(userID int64) (map[int64]ChannelUnread, error) { - rows, err := d.sqlDB.Query( +func (d *DB) GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int64]ChannelUnread, error) { + rows, err := d.sqlDB.QueryContext(ctx, `SELECT c.id, COALESCE(MAX(m.id), 0) AS last_msg_id, COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = 0 THEN 1 END) AS unread @@ -469,9 +470,9 @@ func (d *DB) GetChannelUnreadCounts(userID int64) (map[int64]ChannelUnread, erro } // GetLatestMessageID returns the highest message ID in a channel, or 0 if empty. -func (d *DB) GetLatestMessageID(channelID int64) (int64, error) { +func (d *DB) GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) { var id int64 - err := d.sqlDB.QueryRow( + err := d.sqlDB.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM messages WHERE channel_id = ? AND deleted = 0`, channelID, ).Scan(&id) @@ -483,8 +484,8 @@ func (d *DB) GetLatestMessageID(channelID int64) (int64, error) { // GetPinnedMessages returns all pinned messages in a channel in the API response shape, // including user object, reactions (with me flag), and attachments. -func (d *DB) GetPinnedMessages(channelID int64, requestingUserID int64) ([]MessageAPIResponse, error) { - rows, err := d.sqlDB.Query( +func (d *DB) GetPinnedMessages(ctx context.Context, channelID int64, requestingUserID int64) ([]MessageAPIResponse, error) { + rows, err := d.sqlDB.QueryContext(ctx, `SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp FROM messages m JOIN users u ON m.user_id = u.id @@ -497,12 +498,12 @@ func (d *DB) GetPinnedMessages(channelID int64, requestingUserID int64) ([]Messa } defer rows.Close() //nolint:errcheck - return d.scanAndEnrichMessages(rows, requestingUserID) + return d.scanAndEnrichMessages(ctx, rows, requestingUserID) } // scanAndEnrichMessages scans rows into MessageAPIResponse slice and // batch-fetches reactions and attachments. Caller must defer rows.Close(). -func (d *DB) scanAndEnrichMessages(rows *sql.Rows, requestingUserID int64) ([]MessageAPIResponse, error) { +func (d *DB) scanAndEnrichMessages(ctx context.Context, rows *sql.Rows, requestingUserID int64) ([]MessageAPIResponse, error) { var msgs []MessageAPIResponse var msgIDs []int64 for rows.Next() { @@ -529,7 +530,7 @@ func (d *DB) scanAndEnrichMessages(rows *sql.Rows, requestingUserID int64) ([]Me } // Batch-fetch reactions for all message IDs. - reactMap, err := d.getReactionsBatch(msgIDs, requestingUserID) + reactMap, err := d.getReactionsBatch(ctx, msgIDs, requestingUserID) if err != nil { return nil, fmt.Errorf("scanAndEnrichMessages reactions: %w", err) } @@ -540,7 +541,7 @@ func (d *DB) scanAndEnrichMessages(rows *sql.Rows, requestingUserID int64) ([]Me } // Batch-fetch attachments for all message IDs. - attMap, err := d.GetAttachmentsByMessageIDs(msgIDs) + attMap, err := d.GetAttachmentsByMessageIDs(ctx, msgIDs) if err != nil { return nil, fmt.Errorf("scanAndEnrichMessages attachments: %w", err) } @@ -555,8 +556,8 @@ func (d *DB) scanAndEnrichMessages(rows *sql.Rows, requestingUserID int64) ([]Me // SetMessagePinned updates the pinned column on a message. // Returns ErrNotFound if the message does not exist. -func (d *DB) SetMessagePinned(id int64, pinned bool) error { - res, err := d.q.SetMessagePinned(dbCtx(), dbgen.SetMessagePinnedParams{ +func (d *DB) SetMessagePinned(ctx context.Context, id int64, pinned bool) error { + res, err := d.q.SetMessagePinned(ctx, dbgen.SetMessagePinnedParams{ Pinned: b2i64(pinned), ID: id, }) diff --git a/Server/db/message_queries_test.go b/Server/db/message_queries_test.go index 0372d90c..12a8b8da 100644 --- a/Server/db/message_queries_test.go +++ b/Server/db/message_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "github.com/owncord/server/db" @@ -9,7 +10,7 @@ import ( // seedUser inserts a minimal test user and returns its ID. func seedUser(t *testing.T, database *db.DB, username string) int64 { t.Helper() - id, err := database.CreateUser(username, "hash", 4) + id, err := database.CreateUser(context.Background(), username, "hash", 4) if err != nil { t.Fatalf("seedUser(%q): %v", username, err) } @@ -19,7 +20,7 @@ func seedUser(t *testing.T, database *db.DB, username string) int64 { // seedChannel inserts a minimal test channel and returns its ID. func seedChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "text", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "text", "", "", 0) if err != nil { t.Fatalf("seedChannel(%q): %v", name, err) } @@ -33,7 +34,7 @@ func TestCreateMessage_ReturnsID(t *testing.T) { userID := seedUser(t, database, "alice") chID := seedChannel(t, database, "general") - id, err := database.CreateMessage(chID, userID, "hello", nil) + id, err := database.CreateMessage(context.Background(), chID, userID, "hello", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -47,13 +48,13 @@ func TestCreateMessage_WithReplyTo(t *testing.T) { userID := seedUser(t, database, "alice") chID := seedChannel(t, database, "general") - parentID, _ := database.CreateMessage(chID, userID, "parent", nil) - replyID, err := database.CreateMessage(chID, userID, "reply", &parentID) + parentID, _ := database.CreateMessage(context.Background(), chID, userID, "parent", nil) + replyID, err := database.CreateMessage(context.Background(), chID, userID, "reply", &parentID) if err != nil { t.Fatalf("CreateMessage with reply: %v", err) } - msg, _ := database.GetMessage(replyID) + msg, _ := database.GetMessage(context.Background(), replyID) if msg.ReplyTo == nil || *msg.ReplyTo != parentID { t.Errorf("ReplyTo = %v, want %d", msg.ReplyTo, parentID) } @@ -64,8 +65,8 @@ func TestCreateMessage_ContentPreserved(t *testing.T) { userID := seedUser(t, database, "bob") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "test content", nil) - msg, _ := database.GetMessage(id) + id, _ := database.CreateMessage(context.Background(), chID, userID, "test content", nil) + msg, _ := database.GetMessage(context.Background(), id) if msg.Content != "test content" { t.Errorf("Content = %q, want 'test content'", msg.Content) } @@ -76,7 +77,7 @@ func TestCreateMessage_ContentPreserved(t *testing.T) { func TestGetMessage_NotFound(t *testing.T) { database := openMigratedMemory(t) - msg, err := database.GetMessage(9999) + msg, err := database.GetMessage(context.Background(), 9999) if err != nil { t.Fatalf("GetMessage: %v", err) } @@ -90,9 +91,9 @@ func TestGetMessage_Fields(t *testing.T) { userID := seedUser(t, database, "carol") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "hello world", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "hello world", nil) - msg, err := database.GetMessage(id) + msg, err := database.GetMessage(context.Background(), id) if err != nil { t.Fatalf("GetMessage: %v", err) } @@ -122,7 +123,7 @@ func TestGetMessages_EmptyChannel(t *testing.T) { database := openMigratedMemory(t) chID := seedChannel(t, database, "empty") - msgs, err := database.GetMessages(chID, 0, 50) + msgs, err := database.GetMessages(context.Background(), chID, 0, 50) if err != nil { t.Fatalf("GetMessages: %v", err) } @@ -137,13 +138,13 @@ func TestGetMessages_ReturnsMessages(t *testing.T) { chID := seedChannel(t, database, "ch") for i := range 3 { - _, err := database.CreateMessage(chID, userID, "msg", nil) + _, err := database.CreateMessage(context.Background(), chID, userID, "msg", nil) if err != nil { t.Fatalf("CreateMessage %d: %v", i, err) } } - msgs, err := database.GetMessages(chID, 0, 50) + msgs, err := database.GetMessages(context.Background(), chID, 0, 50) if err != nil { t.Fatalf("GetMessages: %v", err) } @@ -158,10 +159,10 @@ func TestGetMessages_LimitRespected(t *testing.T) { chID := seedChannel(t, database, "ch") for range 10 { - _, _ = database.CreateMessage(chID, userID, "msg", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "msg", nil) } - msgs, _ := database.GetMessages(chID, 0, 5) + msgs, _ := database.GetMessages(context.Background(), chID, 0, 5) if len(msgs) != 5 { t.Errorf("expected 5 messages (limit), got %d", len(msgs)) } @@ -174,12 +175,12 @@ func TestGetMessages_BeforePagination(t *testing.T) { ids := make([]int64, 0, 5) for range 5 { - id, _ := database.CreateMessage(chID, userID, "msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil) ids = append(ids, id) } // Get messages before the 4th message (should get 3 messages: ids 0,1,2). - msgs, _ := database.GetMessages(chID, ids[3], 50) + msgs, _ := database.GetMessages(context.Background(), chID, ids[3], 50) if len(msgs) != 3 { t.Errorf("expected 3 messages before id %d, got %d", ids[3], len(msgs)) } @@ -190,8 +191,8 @@ func TestGetMessages_IncludesUsername(t *testing.T) { userID := seedUser(t, database, "grace") chID := seedChannel(t, database, "ch") - _, _ = database.CreateMessage(chID, userID, "hi", nil) - msgs, _ := database.GetMessages(chID, 0, 50) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hi", nil) + msgs, _ := database.GetMessages(context.Background(), chID, 0, 50) if len(msgs) == 0 { t.Fatal("expected messages") @@ -208,13 +209,13 @@ func TestEditMessage_OwnerCanEdit(t *testing.T) { userID := seedUser(t, database, "henry") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "original", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "original", nil) - if err := database.EditMessage(id, userID, "updated"); err != nil { + if err := database.EditMessage(context.Background(), id, userID, "updated"); err != nil { t.Fatalf("EditMessage: %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if msg.Content != "updated" { t.Errorf("Content = %q, want 'updated'", msg.Content) } @@ -229,9 +230,9 @@ func TestEditMessage_NonOwnerCannotEdit(t *testing.T) { otherID := seedUser(t, database, "julia") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, ownerID, "original", nil) + id, _ := database.CreateMessage(context.Background(), chID, ownerID, "original", nil) - err := database.EditMessage(id, otherID, "hacked") + err := database.EditMessage(context.Background(), id, otherID, "hacked") if err == nil { t.Error("EditMessage by non-owner should return error") } @@ -241,7 +242,7 @@ func TestEditMessage_NotFound(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "kim") - err := database.EditMessage(9999, userID, "x") + err := database.EditMessage(context.Background(), 9999, userID, "x") if err == nil { t.Error("EditMessage non-existent should return error") } @@ -254,13 +255,13 @@ func TestDeleteMessage_OwnerCanDelete(t *testing.T) { userID := seedUser(t, database, "larry") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "bye", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "bye", nil) - if err := database.DeleteMessage(id, userID, false); err != nil { + if err := database.DeleteMessage(context.Background(), id, userID, false); err != nil { t.Fatalf("DeleteMessage: %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if msg == nil { t.Fatal("soft-deleted message should still exist in DB") } @@ -274,10 +275,10 @@ func TestDeleteMessage_ContentPreservedAfterSoftDelete(t *testing.T) { userID := seedUser(t, database, "mia") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "sensitive", nil) - _ = database.DeleteMessage(id, userID, false) + id, _ := database.CreateMessage(context.Background(), chID, userID, "sensitive", nil) + _ = database.DeleteMessage(context.Background(), id, userID, false) - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) // Content preserved for broadcast (soft delete only flags deleted=1). if msg.Content == "" { t.Error("content should be preserved on soft delete for broadcast purposes") @@ -290,9 +291,9 @@ func TestDeleteMessage_NonOwnerBlockedWithoutMod(t *testing.T) { otherID := seedUser(t, database, "olivia") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, ownerID, "msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, ownerID, "msg", nil) - err := database.DeleteMessage(id, otherID, false) + err := database.DeleteMessage(context.Background(), id, otherID, false) if err == nil { t.Error("DeleteMessage by non-owner non-mod should return error") } @@ -304,13 +305,13 @@ func TestDeleteMessage_ModCanDeleteAny(t *testing.T) { modID := seedUser(t, database, "quinn") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, ownerID, "msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, ownerID, "msg", nil) - if err := database.DeleteMessage(id, modID, true); err != nil { + if err := database.DeleteMessage(context.Background(), id, modID, true); err != nil { t.Fatalf("DeleteMessage by mod: %v", err) } - msg, _ := database.GetMessage(id) + msg, _ := database.GetMessage(context.Background(), id) if !msg.Deleted { t.Error("expected Deleted=true after mod delete") } @@ -320,7 +321,7 @@ func TestDeleteMessage_NotFound(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "rachel") - err := database.DeleteMessage(9999, userID, true) + err := database.DeleteMessage(context.Background(), 9999, userID, true) if err == nil { t.Error("DeleteMessage non-existent should return error") } @@ -332,9 +333,9 @@ func TestAddReaction_Success(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "sam") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - if err := database.AddReaction(msgID, userID, "👍"); err != nil { + if err := database.AddReaction(context.Background(), msgID, userID, "👍"); err != nil { t.Fatalf("AddReaction: %v", err) } } @@ -343,10 +344,10 @@ func TestAddReaction_UniqueConstraint(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "tina") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - _ = database.AddReaction(msgID, userID, "❤️") - err := database.AddReaction(msgID, userID, "❤️") + _ = database.AddReaction(context.Background(), msgID, userID, "❤️") + err := database.AddReaction(context.Background(), msgID, userID, "❤️") if err == nil { t.Error("adding duplicate reaction should return error") } @@ -356,10 +357,10 @@ func TestRemoveReaction_Success(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "uma") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - _ = database.AddReaction(msgID, userID, "😂") - if err := database.RemoveReaction(msgID, userID, "😂"); err != nil { + _ = database.AddReaction(context.Background(), msgID, userID, "😂") + if err := database.RemoveReaction(context.Background(), msgID, userID, "😂"); err != nil { t.Fatalf("RemoveReaction: %v", err) } } @@ -368,9 +369,9 @@ func TestRemoveReaction_NotFound(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "victor") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - err := database.RemoveReaction(msgID, userID, "🔥") + err := database.RemoveReaction(context.Background(), msgID, userID, "🔥") if err == nil { t.Error("removing non-existent reaction should return error") } @@ -380,9 +381,9 @@ func TestGetReactions_Empty(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "wendy") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hi", nil) - counts, err := database.GetReactions(msgID) + counts, err := database.GetReactions(context.Background(), msgID) if err != nil { t.Fatalf("GetReactions: %v", err) } @@ -396,13 +397,13 @@ func TestGetReactions_Counts(t *testing.T) { u1 := seedUser(t, database, "xavier") u2 := seedUser(t, database, "yvonne") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, u1, "hi", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, u1, "hi", nil) - _ = database.AddReaction(msgID, u1, "👍") - _ = database.AddReaction(msgID, u2, "👍") - _ = database.AddReaction(msgID, u1, "❤️") + _ = database.AddReaction(context.Background(), msgID, u1, "👍") + _ = database.AddReaction(context.Background(), msgID, u2, "👍") + _ = database.AddReaction(context.Background(), msgID, u1, "❤️") - counts, _ := database.GetReactions(msgID) + counts, _ := database.GetReactions(context.Background(), msgID) if len(counts) != 2 { t.Fatalf("expected 2 emoji types, got %d", len(counts)) } @@ -429,10 +430,10 @@ func TestSearchMessages_FindsMatch(t *testing.T) { userID := seedUser(t, database, "zara") chID := seedChannel(t, database, "searchch") - _, _ = database.CreateMessage(chID, userID, "hello world fts test", nil) - _, _ = database.CreateMessage(chID, userID, "unrelated content here", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hello world fts test", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "unrelated content here", nil) - results, err := database.SearchMessages("hello", nil, 10) + results, err := database.SearchMessages(context.Background(), "hello", nil, 10) if err != nil { t.Fatalf("SearchMessages: %v", err) } @@ -450,10 +451,10 @@ func TestSearchMessages_FilterByChannel(t *testing.T) { ch1 := seedChannel(t, database, "ch1") ch2 := seedChannel(t, database, "ch2") - _, _ = database.CreateMessage(ch1, userID, "needle in channel 1", nil) - _, _ = database.CreateMessage(ch2, userID, "needle in channel 2", nil) + _, _ = database.CreateMessage(context.Background(), ch1, userID, "needle in channel 1", nil) + _, _ = database.CreateMessage(context.Background(), ch2, userID, "needle in channel 2", nil) - results, _ := database.SearchMessages("needle", &ch1, 10) + results, _ := database.SearchMessages(context.Background(), "needle", &ch1, 10) if len(results) != 1 { t.Errorf("expected 1 result in ch1, got %d", len(results)) } @@ -466,9 +467,9 @@ func TestSearchMessages_NoResults(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "beth") chID := seedChannel(t, database, "ch") - _, _ = database.CreateMessage(chID, userID, "hello there", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hello there", nil) - results, _ := database.SearchMessages("xyzzy", nil, 10) + results, _ := database.SearchMessages(context.Background(), "xyzzy", nil, 10) if len(results) != 0 { t.Errorf("expected 0 results, got %d", len(results)) } @@ -480,10 +481,10 @@ func TestSearchMessages_LimitRespected(t *testing.T) { chID := seedChannel(t, database, "ch") for range 5 { - _, _ = database.CreateMessage(chID, userID, "searchable keyword content", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "searchable keyword content", nil) } - results, _ := database.SearchMessages("keyword", nil, 3) + results, _ := database.SearchMessages(context.Background(), "keyword", nil, 3) if len(results) != 3 { t.Errorf("expected 3 results (limit), got %d", len(results)) } @@ -494,10 +495,10 @@ func TestSearchMessages_DeletedNotReturned(t *testing.T) { userID := seedUser(t, database, "diana") chID := seedChannel(t, database, "ch") - id, _ := database.CreateMessage(chID, userID, "vanishing keyword message", nil) - _ = database.DeleteMessage(id, userID, false) + id, _ := database.CreateMessage(context.Background(), chID, userID, "vanishing keyword message", nil) + _ = database.DeleteMessage(context.Background(), id, userID, false) - results, _ := database.SearchMessages("vanishing", nil, 10) + results, _ := database.SearchMessages(context.Background(), "vanishing", nil, 10) if len(results) != 0 { t.Errorf("expected 0 results (deleted excluded), got %d", len(results)) } @@ -509,15 +510,15 @@ func TestUpdateReadState_Upsert(t *testing.T) { database := openMigratedMemory(t) userID := seedUser(t, database, "ella") chID := seedChannel(t, database, "ch") - msgID, _ := database.CreateMessage(chID, userID, "msg", nil) + msgID, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil) - if err := database.UpdateReadState(userID, chID, msgID); err != nil { + if err := database.UpdateReadState(context.Background(), userID, chID, msgID); err != nil { t.Fatalf("UpdateReadState: %v", err) } // Update again with higher message ID — should not error. - msgID2, _ := database.CreateMessage(chID, userID, "msg2", nil) - if err := database.UpdateReadState(userID, chID, msgID2); err != nil { + msgID2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil) + if err := database.UpdateReadState(context.Background(), userID, chID, msgID2); err != nil { t.Fatalf("UpdateReadState second call: %v", err) } } @@ -529,7 +530,7 @@ func TestGetMessagesForAPI_Empty(t *testing.T) { chID := seedChannel(t, database, "apichan") userID := seedUser(t, database, "apiuser") - msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID) if err != nil { t.Fatalf("GetMessagesForAPI: %v", err) } @@ -543,9 +544,9 @@ func TestGetMessagesForAPI_ReturnsUserObject(t *testing.T) { userID := seedUser(t, database, "apiuser2") chID := seedChannel(t, database, "apichan2") - _, _ = database.CreateMessage(chID, userID, "hello api", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "hello api", nil) - msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID) if err != nil { t.Fatalf("GetMessagesForAPI: %v", err) } @@ -570,11 +571,11 @@ func TestGetMessagesForAPI_BeforePagination(t *testing.T) { ids := make([]int64, 0, 5) for range 5 { - id, _ := database.CreateMessage(chID, userID, "msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "msg", nil) ids = append(ids, id) } - msgs, err := database.GetMessagesForAPI(chID, ids[3], 50, userID) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, ids[3], 50, userID) if err != nil { t.Fatalf("GetMessagesForAPI with before: %v", err) } @@ -589,11 +590,11 @@ func TestGetMessagesForAPI_WithReactions(t *testing.T) { u2 := seedUser(t, database, "reactuser2") chID := seedChannel(t, database, "reactchan") - msgID, _ := database.CreateMessage(chID, u1, "react me", nil) - _ = database.AddReaction(msgID, u1, "👍") - _ = database.AddReaction(msgID, u2, "👍") + msgID, _ := database.CreateMessage(context.Background(), chID, u1, "react me", nil) + _ = database.AddReaction(context.Background(), msgID, u1, "👍") + _ = database.AddReaction(context.Background(), msgID, u2, "👍") - msgs, err := database.GetMessagesForAPI(chID, 0, 50, u1) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, u1) if err != nil { t.Fatalf("GetMessagesForAPI: %v", err) } @@ -616,11 +617,11 @@ func TestGetMessagesForAPI_ExcludesDeleted(t *testing.T) { userID := seedUser(t, database, "apidel") chID := seedChannel(t, database, "apidelchan") - id, _ := database.CreateMessage(chID, userID, "deleted msg", nil) - _ = database.DeleteMessage(id, userID, false) - _, _ = database.CreateMessage(chID, userID, "visible msg", nil) + id, _ := database.CreateMessage(context.Background(), chID, userID, "deleted msg", nil) + _ = database.DeleteMessage(context.Background(), id, userID, false) + _, _ = database.CreateMessage(context.Background(), chID, userID, "visible msg", nil) - msgs, err := database.GetMessagesForAPI(chID, 0, 50, userID) + msgs, err := database.GetMessagesForAPI(context.Background(), chID, 0, 50, userID) if err != nil { t.Fatalf("GetMessagesForAPI: %v", err) } @@ -636,7 +637,7 @@ func TestGetChannelUnreadCounts_NoMessages(t *testing.T) { userID := seedUser(t, database, "unreaduser") _ = seedChannel(t, database, "unreadchan") - counts, err := database.GetChannelUnreadCounts(userID) + counts, err := database.GetChannelUnreadCounts(context.Background(), userID) if err != nil { t.Fatalf("GetChannelUnreadCounts: %v", err) } @@ -652,13 +653,13 @@ func TestGetChannelUnreadCounts_WithUnreadMessages(t *testing.T) { chID := seedChannel(t, database, "unreadchan2") // Create 3 messages, mark first as read. - msg1, _ := database.CreateMessage(chID, userID, "msg1", nil) - _, _ = database.CreateMessage(chID, userID, "msg2", nil) - _, _ = database.CreateMessage(chID, userID, "msg3", nil) + msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "msg2", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "msg3", nil) - _ = database.UpdateReadState(userID, chID, msg1) + _ = database.UpdateReadState(context.Background(), userID, chID, msg1) - counts, err := database.GetChannelUnreadCounts(userID) + counts, err := database.GetChannelUnreadCounts(context.Background(), userID) if err != nil { t.Fatalf("GetChannelUnreadCounts: %v", err) } @@ -677,7 +678,7 @@ func TestGetLatestMessageID_Empty(t *testing.T) { database := openMigratedMemory(t) chID := seedChannel(t, database, "latestchan") - id, err := database.GetLatestMessageID(chID) + id, err := database.GetLatestMessageID(context.Background(), chID) if err != nil { t.Fatalf("GetLatestMessageID: %v", err) } @@ -691,11 +692,11 @@ func TestGetLatestMessageID_ReturnsHighest(t *testing.T) { userID := seedUser(t, database, "latestuser") chID := seedChannel(t, database, "latestchan2") - _, _ = database.CreateMessage(chID, userID, "first", nil) - _, _ = database.CreateMessage(chID, userID, "second", nil) - lastID, _ := database.CreateMessage(chID, userID, "third", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "first", nil) + _, _ = database.CreateMessage(context.Background(), chID, userID, "second", nil) + lastID, _ := database.CreateMessage(context.Background(), chID, userID, "third", nil) - id, err := database.GetLatestMessageID(chID) + id, err := database.GetLatestMessageID(context.Background(), chID) if err != nil { t.Fatalf("GetLatestMessageID: %v", err) } @@ -709,11 +710,11 @@ func TestGetLatestMessageID_ExcludesDeleted(t *testing.T) { userID := seedUser(t, database, "latestdel") chID := seedChannel(t, database, "latestdelchan") - id1, _ := database.CreateMessage(chID, userID, "keep", nil) - id2, _ := database.CreateMessage(chID, userID, "delete me", nil) - _ = database.DeleteMessage(id2, userID, false) + id1, _ := database.CreateMessage(context.Background(), chID, userID, "keep", nil) + id2, _ := database.CreateMessage(context.Background(), chID, userID, "delete me", nil) + _ = database.DeleteMessage(context.Background(), id2, userID, false) - latestID, err := database.GetLatestMessageID(chID) + latestID, err := database.GetLatestMessageID(context.Background(), chID) if err != nil { t.Fatalf("GetLatestMessageID: %v", err) } diff --git a/Server/db/migrate_test.go b/Server/db/migrate_test.go index 508064a9..f3529284 100644 --- a/Server/db/migrate_test.go +++ b/Server/db/migrate_test.go @@ -21,6 +21,7 @@ package db_test // TestMigrate_AppliedAtIsISO8601 — applied_at timestamp format is valid import ( + "context" "database/sql" "fmt" "io/fs" @@ -58,7 +59,7 @@ func (badDirFile) ReadDir(int) ([]fs.DirEntry, error) { func countVersions(t *testing.T, database *db.DB) int { t.Helper() var n int - err := database.QueryRow("SELECT COUNT(*) FROM schema_versions").Scan(&n) + err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_versions").Scan(&n) if err != nil { t.Fatalf("counting schema_versions: %v", err) } @@ -69,7 +70,7 @@ func countVersions(t *testing.T, database *db.DB) int { func hasVersion(t *testing.T, database *db.DB, filename string) bool { t.Helper() var v string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT version FROM schema_versions WHERE version = ?", filename, ).Scan(&v) if err == sql.ErrNoRows { @@ -85,7 +86,7 @@ func hasVersion(t *testing.T, database *db.DB, filename string) bool { func tableExists(t *testing.T, database *db.DB, name string) bool { t.Helper() var n string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name=?", name, ).Scan(&n) if err == sql.ErrNoRows { @@ -175,7 +176,7 @@ func TestMigrate_SkipsAlreadyApplied(t *testing.T) { // Confirm the row exists exactly once. var count int - if err := database.QueryRow("SELECT COUNT(*) FROM unique_check WHERE val='singleton'").Scan(&count); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM unique_check WHERE val='singleton'").Scan(&count); err != nil { t.Fatalf("counting unique_check: %v", err) } if count != 1 { @@ -246,7 +247,7 @@ func TestMigrate_OrderIsLexicographic(t *testing.T) { } var label string - if err := database.QueryRow("SELECT label FROM order_check LIMIT 1").Scan(&label); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT label FROM order_check LIMIT 1").Scan(&label); err != nil { t.Fatalf("selecting from order_check: %v", err) } if label != "second" { @@ -263,7 +264,7 @@ func TestMigrate_SeedExistingDatabase(t *testing.T) { // Manually create a table to simulate a previously-migrated database // that does not yet have schema_versions. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);", ); err != nil { t.Fatalf("setup: creating users table: %v", err) @@ -289,7 +290,7 @@ func TestMigrate_SeedExistingDatabase(t *testing.T) { // The users table must still have its original schema (no 'name' column), // proving the DROP/CREATE did not run. - _, err := database.Exec("INSERT INTO users (id) VALUES (42)") + _, err := database.ExecContext(context.Background(), "INSERT INTO users (id) VALUES (42)") if err != nil { t.Errorf("users table appears to have been recreated (DROP ran): %v", err) } @@ -304,12 +305,12 @@ func TestMigrate_SeedDoesNotReRunMigrations(t *testing.T) { // Simulate an existing DB: create the "users" sentinel table so the seeding // heuristic fires, plus the table that the migration would modify. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);", ); err != nil { t.Fatalf("setup users: %v", err) } - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS existing (id INTEGER PRIMARY KEY);", ); err != nil { t.Fatalf("setup existing: %v", err) @@ -335,7 +336,7 @@ func TestMigrate_SeedDoesNotReRunMigrations(t *testing.T) { // existing table should be empty — the INSERT was never executed (seeded only). var count int - if err := database.QueryRow("SELECT COUNT(*) FROM existing").Scan(&count); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM existing").Scan(&count); err != nil { t.Fatalf("counting existing: %v", err) } if count != 0 { @@ -357,7 +358,7 @@ func TestMigrate_SchemaVersionsAppliedAtRecorded(t *testing.T) { } var appliedAt string - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), "SELECT applied_at FROM schema_versions WHERE version = '001_ts.sql'", ).Scan(&appliedAt) if err != nil { @@ -381,7 +382,7 @@ func TestMigrate_AppliedAtIsISO8601(t *testing.T) { } var appliedAt string - if err := database.QueryRow( + if err := database.QueryRowContext(context.Background(), "SELECT applied_at FROM schema_versions WHERE version = '001_dt.sql'", ).Scan(&appliedAt); err != nil { t.Fatalf("querying applied_at: %v", err) @@ -538,7 +539,7 @@ func TestMigrate_SeedDetectionUsesKnownTable(t *testing.T) { database := openMemory(t) // Create only an unrelated table — not one of the known sentinel tables. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS unrelated (id INTEGER PRIMARY KEY);", ); err != nil { t.Fatalf("setup: %v", err) @@ -603,7 +604,7 @@ func TestMigrate_SchemaVersionsHasPrimaryKey(t *testing.T) { } // Attempting a duplicate insert must fail. - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), "INSERT INTO schema_versions (version, applied_at) VALUES ('001_pk.sql', datetime('now'))", ) if err == nil { @@ -617,7 +618,7 @@ func TestMigrate_SeedRecordsAllFilesFromFS(t *testing.T) { database := openMemory(t) // Create the users sentinel to trigger seeding on first call. - if _, err := database.Exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"); err != nil { + if _, err := database.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"); err != nil { t.Fatalf("setup: %v", err) } diff --git a/Server/db/profile_queries.go b/Server/db/profile_queries.go index 027719e9..9310a9f0 100644 --- a/Server/db/profile_queries.go +++ b/Server/db/profile_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "fmt" "github.com/owncord/server/db/dbgen" @@ -9,8 +10,8 @@ import ( // UpdateUserProfile updates the username and avatar for the given user. // Returns ErrNotFound if the user does not exist. Returns an error wrapping // a UNIQUE constraint violation if the username is already taken. -func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) error { - result, err := d.q.UpdateUserProfile(dbCtx(), dbgen.UpdateUserProfileParams{ +func (d *DB) UpdateUserProfile(ctx context.Context, userID int64, username string, avatar *string) error { + result, err := d.q.UpdateUserProfile(ctx, dbgen.UpdateUserProfileParams{ Username: username, Avatar: avatar, ID: userID, @@ -29,8 +30,8 @@ func (d *DB) UpdateUserProfile(userID int64, username string, avatar *string) er } // UpdateUserPassword sets a new password hash for the given user. -func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error { - if err := d.q.UpdateUserPassword(dbCtx(), dbgen.UpdateUserPasswordParams{ +func (d *DB) UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error { + if err := d.q.UpdateUserPassword(ctx, dbgen.UpdateUserPasswordParams{ Password: newPasswordHash, ID: userID, }); err != nil { @@ -41,8 +42,8 @@ func (d *DB) UpdateUserPassword(userID int64, newPasswordHash string) error { // ListUserSessions returns all sessions for the given user in a single query. // Results are ordered by created_at descending (newest first). -func (d *DB) ListUserSessions(userID int64) ([]Session, error) { - rows, err := d.q.ListUserSessions(dbCtx(), userID) +func (d *DB) ListUserSessions(ctx context.Context, userID int64) ([]Session, error) { + rows, err := d.q.ListUserSessions(ctx, userID) if err != nil { return nil, fmt.Errorf("ListUserSessions: %w", err) } @@ -56,8 +57,8 @@ func (d *DB) ListUserSessions(userID int64) ([]Session, error) { // DeleteSessionByID removes a session by its ID, but only if it belongs to // the specified user. Returns ErrNotFound if the session does not exist or // does not belong to the user. -func (d *DB) DeleteSessionByID(sessionID, userID int64) error { - result, err := d.q.DeleteSessionByID(dbCtx(), dbgen.DeleteSessionByIDParams{ +func (d *DB) DeleteSessionByID(ctx context.Context, sessionID, userID int64) error { + result, err := d.q.DeleteSessionByID(ctx, dbgen.DeleteSessionByIDParams{ ID: sessionID, UserID: userID, }) diff --git a/Server/db/profile_queries_test.go b/Server/db/profile_queries_test.go index 6dc6aed1..8ba2f669 100644 --- a/Server/db/profile_queries_test.go +++ b/Server/db/profile_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" ) @@ -8,17 +9,17 @@ import ( func TestUpdateUserProfile_UsernameAndAvatar(t *testing.T) { database := newTestDB(t) - id, err := database.CreateUser("profileuser", "hash", 4) + id, err := database.CreateUser(context.Background(), "profileuser", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } avatar := "https://example.com/avatar.png" - if err := database.UpdateUserProfile(id, "newname", &avatar); err != nil { + if err := database.UpdateUserProfile(context.Background(), id, "newname", &avatar); err != nil { t.Fatalf("UpdateUserProfile: %v", err) } - user, err := database.GetUserByID(id) + user, err := database.GetUserByID(context.Background(), id) if err != nil { t.Fatalf("GetUserByID: %v", err) } @@ -32,13 +33,13 @@ func TestUpdateUserProfile_UsernameAndAvatar(t *testing.T) { func TestUpdateUserProfile_UsernameOnly(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("keepavatar", "hash", 4) + id, _ := database.CreateUser(context.Background(), "keepavatar", "hash", 4) - if err := database.UpdateUserProfile(id, "renamed", nil); err != nil { + if err := database.UpdateUserProfile(context.Background(), id, "renamed", nil); err != nil { t.Fatalf("UpdateUserProfile: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if user.Username != "renamed" { t.Errorf("Username = %q, want %q", user.Username, "renamed") } @@ -49,10 +50,10 @@ func TestUpdateUserProfile_UsernameOnly(t *testing.T) { func TestUpdateUserProfile_DuplicateUsername(t *testing.T) { database := newTestDB(t) - database.CreateUser("existing", "hash", 4) - id2, _ := database.CreateUser("changeme", "hash", 4) + database.CreateUser(context.Background(), "existing", "hash", 4) + id2, _ := database.CreateUser(context.Background(), "changeme", "hash", 4) - err := database.UpdateUserProfile(id2, "existing", nil) + err := database.UpdateUserProfile(context.Background(), id2, "existing", nil) if err == nil { t.Error("UpdateUserProfile with duplicate username should return error") } @@ -60,7 +61,7 @@ func TestUpdateUserProfile_DuplicateUsername(t *testing.T) { func TestUpdateUserProfile_NonExistentUser(t *testing.T) { database := newTestDB(t) - err := database.UpdateUserProfile(99999, "ghost", nil) + err := database.UpdateUserProfile(context.Background(), 99999, "ghost", nil) if err == nil { t.Error("UpdateUserProfile for non-existent user should return error") } @@ -70,13 +71,13 @@ func TestUpdateUserProfile_NonExistentUser(t *testing.T) { func TestUpdateUserPassword_Success(t *testing.T) { database := newTestDB(t) - id, _ := database.CreateUser("pwuser", "oldhash", 4) + id, _ := database.CreateUser(context.Background(), "pwuser", "oldhash", 4) - if err := database.UpdateUserPassword(id, "newhash"); err != nil { + if err := database.UpdateUserPassword(context.Background(), id, "newhash"); err != nil { t.Fatalf("UpdateUserPassword: %v", err) } - user, _ := database.GetUserByID(id) + user, _ := database.GetUserByID(context.Background(), id) if user.PasswordHash != "newhash" { t.Errorf("PasswordHash = %q, want %q", user.PasswordHash, "newhash") } @@ -86,12 +87,12 @@ func TestUpdateUserPassword_Success(t *testing.T) { func TestListUserSessions_ReturnsSessions(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("sessuser", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "sessuser", "hash", 4) - database.CreateSession(uid, "tok1", "Chrome", "1.2.3.4") - database.CreateSession(uid, "tok2", "Firefox", "5.6.7.8") + database.CreateSession(context.Background(), uid, "tok1", "Chrome", "1.2.3.4") + database.CreateSession(context.Background(), uid, "tok2", "Firefox", "5.6.7.8") - sessions, err := database.ListUserSessions(uid) + sessions, err := database.ListUserSessions(context.Background(), uid) if err != nil { t.Fatalf("ListUserSessions: %v", err) } @@ -102,9 +103,9 @@ func TestListUserSessions_ReturnsSessions(t *testing.T) { func TestListUserSessions_EmptyArray(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("nosess", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "nosess", "hash", 4) - sessions, err := database.ListUserSessions(uid) + sessions, err := database.ListUserSessions(context.Background(), uid) if err != nil { t.Fatalf("ListUserSessions: %v", err) } @@ -118,13 +119,13 @@ func TestListUserSessions_EmptyArray(t *testing.T) { func TestListUserSessions_DoesNotReturnOtherUsers(t *testing.T) { database := newTestDB(t) - uid1, _ := database.CreateUser("user1", "hash", 4) - uid2, _ := database.CreateUser("user2", "hash", 4) + uid1, _ := database.CreateUser(context.Background(), "user1", "hash", 4) + uid2, _ := database.CreateUser(context.Background(), "user2", "hash", 4) - database.CreateSession(uid1, "tok-u1", "Chrome", "1.2.3.4") - database.CreateSession(uid2, "tok-u2", "Firefox", "5.6.7.8") + database.CreateSession(context.Background(), uid1, "tok-u1", "Chrome", "1.2.3.4") + database.CreateSession(context.Background(), uid2, "tok-u2", "Firefox", "5.6.7.8") - sessions, _ := database.ListUserSessions(uid1) + sessions, _ := database.ListUserSessions(context.Background(), uid1) if len(sessions) != 1 { t.Errorf("len(sessions) = %d, want 1", len(sessions)) } @@ -134,16 +135,16 @@ func TestListUserSessions_DoesNotReturnOtherUsers(t *testing.T) { func TestDeleteSessionByID_Success(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("delsess", "hash", 4) - sessID, _ := database.CreateSession(uid, "deltok", "Chrome", "1.2.3.4") + uid, _ := database.CreateUser(context.Background(), "delsess", "hash", 4) + sessID, _ := database.CreateSession(context.Background(), uid, "deltok", "Chrome", "1.2.3.4") - err := database.DeleteSessionByID(sessID, uid) + err := database.DeleteSessionByID(context.Background(), sessID, uid) if err != nil { t.Fatalf("DeleteSessionByID: %v", err) } // Session should be gone. - sess, _ := database.GetSessionByTokenHash("deltok") + sess, _ := database.GetSessionByTokenHash(context.Background(), "deltok") if sess != nil { t.Error("session should have been deleted") } @@ -151,11 +152,11 @@ func TestDeleteSessionByID_Success(t *testing.T) { func TestDeleteSessionByID_WrongOwner(t *testing.T) { database := newTestDB(t) - uid1, _ := database.CreateUser("owner1", "hash", 4) - uid2, _ := database.CreateUser("owner2", "hash", 4) - sessID, _ := database.CreateSession(uid1, "ownertok", "Chrome", "1.2.3.4") + uid1, _ := database.CreateUser(context.Background(), "owner1", "hash", 4) + uid2, _ := database.CreateUser(context.Background(), "owner2", "hash", 4) + sessID, _ := database.CreateSession(context.Background(), uid1, "ownertok", "Chrome", "1.2.3.4") - err := database.DeleteSessionByID(sessID, uid2) + err := database.DeleteSessionByID(context.Background(), sessID, uid2) if err == nil { t.Error("DeleteSessionByID should fail when user does not own the session") } @@ -163,9 +164,9 @@ func TestDeleteSessionByID_WrongOwner(t *testing.T) { func TestDeleteSessionByID_NotFound(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("delnf", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "delnf", "hash", 4) - err := database.DeleteSessionByID(99999, uid) + err := database.DeleteSessionByID(context.Background(), 99999, uid) if err == nil { t.Error("DeleteSessionByID should fail for non-existent session") } diff --git a/Server/db/role_invite_queries_test.go b/Server/db/role_invite_queries_test.go index 24f04f66..8b38a063 100644 --- a/Server/db/role_invite_queries_test.go +++ b/Server/db/role_invite_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" ) @@ -9,7 +10,7 @@ import ( func TestGetRoleByID_Found(t *testing.T) { database := newTestDB(t) - role, err := database.GetRoleByID(4) // Member — inserted by migration + role, err := database.GetRoleByID(context.Background(), 4) // Member — inserted by migration if err != nil { t.Fatalf("GetRoleByID: %v", err) } @@ -27,7 +28,7 @@ func TestGetRoleByID_Found(t *testing.T) { func TestGetRoleByID_NotFound(t *testing.T) { database := newTestDB(t) - role, err := database.GetRoleByID(9999) + role, err := database.GetRoleByID(context.Background(), 9999) if err != nil { t.Fatalf("GetRoleByID(not found): %v", err) } @@ -39,7 +40,7 @@ func TestGetRoleByID_NotFound(t *testing.T) { func TestGetRoleByID_OwnerHasAllPermissions(t *testing.T) { database := newTestDB(t) - role, err := database.GetRoleByID(1) // Owner + role, err := database.GetRoleByID(context.Background(), 1) // Owner if err != nil { t.Fatalf("GetRoleByID Owner: %v", err) } @@ -55,8 +56,8 @@ func TestGetRoleByID_OwnerHasAllPermissions(t *testing.T) { func TestGetRoleByID_IsDefaultField(t *testing.T) { database := newTestDB(t) - owner, _ := database.GetRoleByID(1) - member, _ := database.GetRoleByID(4) + owner, _ := database.GetRoleByID(context.Background(), 1) + member, _ := database.GetRoleByID(context.Background(), 4) if owner.IsDefault { t.Error("Owner.IsDefault = true, want false") @@ -72,7 +73,7 @@ func TestGetRoleByID_IsDefaultField(t *testing.T) { func TestListRoles_ReturnsFourDefaultRoles(t *testing.T) { database := newTestDB(t) - roles, err := database.ListRoles() + roles, err := database.ListRoles(context.Background()) if err != nil { t.Fatalf("ListRoles: %v", err) } @@ -84,7 +85,7 @@ func TestListRoles_ReturnsFourDefaultRoles(t *testing.T) { func TestListRoles_OrderedByPositionDesc(t *testing.T) { database := newTestDB(t) - roles, err := database.ListRoles() + roles, err := database.ListRoles(context.Background()) if err != nil { t.Fatalf("ListRoles: %v", err) } @@ -101,12 +102,12 @@ func TestListRoles_OrderedByPositionDesc(t *testing.T) { func TestGetUserWithRole_Found(t *testing.T) { database := newTestDB(t) - uid, err := database.CreateUser("joinuser", "hash", 4) // Member role + uid, err := database.CreateUser(context.Background(), "joinuser", "hash", 4) // Member role if err != nil { t.Fatalf("CreateUser: %v", err) } - user, role, err := database.GetUserWithRole(uid) + user, role, err := database.GetUserWithRole(context.Background(), uid) if err != nil { t.Fatalf("GetUserWithRole: %v", err) } @@ -133,7 +134,7 @@ func TestGetUserWithRole_Found(t *testing.T) { func TestGetUserWithRole_NotFound(t *testing.T) { database := newTestDB(t) - user, role, err := database.GetUserWithRole(9999) + user, role, err := database.GetUserWithRole(context.Background(), 9999) if err != nil { t.Fatalf("GetUserWithRole(not found): %v", err) } @@ -144,9 +145,9 @@ func TestGetUserWithRole_NotFound(t *testing.T) { func TestGetUserWithRole_BoolConversions(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("booluser", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "booluser", "hash", 4) - user, role, err := database.GetUserWithRole(uid) + user, role, err := database.GetUserWithRole(context.Background(), uid) if err != nil { t.Fatalf("GetUserWithRole: %v", err) } @@ -165,7 +166,7 @@ func TestGetUserWithRole_BoolConversions(t *testing.T) { func TestListInvites_Empty(t *testing.T) { database := newTestDB(t) - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites empty: %v", err) } @@ -176,13 +177,13 @@ func TestListInvites_Empty(t *testing.T) { func TestListInvites_Multiple(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("listowner", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "listowner", "hash", 4) - _, _ = database.CreateInvite(uid, 1, nil) - _, _ = database.CreateInvite(uid, 5, nil) - _, _ = database.CreateInvite(uid, 0, nil) + _, _ = database.CreateInvite(context.Background(), uid, 1, nil) + _, _ = database.CreateInvite(context.Background(), uid, 5, nil) + _, _ = database.CreateInvite(context.Background(), uid, 0, nil) - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites multiple: %v", err) } @@ -193,13 +194,13 @@ func TestListInvites_Multiple(t *testing.T) { func TestListInvites_IncludesRevokedInvites(t *testing.T) { database := newTestDB(t) - uid, _ := database.CreateUser("revokelistowner", "hash", 4) + uid, _ := database.CreateUser(context.Background(), "revokelistowner", "hash", 4) - code, _ := database.CreateInvite(uid, 1, nil) - _ = database.RevokeInvite(code) - _, _ = database.CreateInvite(uid, 0, nil) // active + code, _ := database.CreateInvite(context.Background(), uid, 1, nil) + _ = database.RevokeInvite(context.Background(), code) + _, _ = database.CreateInvite(context.Background(), uid, 0, nil) // active - invites, err := database.ListInvites() + invites, err := database.ListInvites(context.Background()) if err != nil { t.Fatalf("ListInvites with revoked: %v", err) } diff --git a/Server/db/role_queries.go b/Server/db/role_queries.go index 5c63c376..3b05637f 100644 --- a/Server/db/role_queries.go +++ b/Server/db/role_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -23,8 +24,8 @@ func roleFromGen(r dbgen.Role) *Role { } // GetRoleByID returns the role with the given ID, or nil if not found. -func (d *DB) GetRoleByID(id int64) (*Role, error) { - r, err := d.q.GetRoleByID(dbCtx(), id) +func (d *DB) GetRoleByID(ctx context.Context, id int64) (*Role, error) { + r, err := d.q.GetRoleByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -35,8 +36,8 @@ func (d *DB) GetRoleByID(id int64) (*Role, error) { } // ListRoles returns all roles ordered by position descending. -func (d *DB) ListRoles() ([]*Role, error) { - rows, err := d.q.ListRoles(dbCtx()) +func (d *DB) ListRoles(ctx context.Context) ([]*Role, error) { + rows, err := d.q.ListRoles(ctx) if err != nil { return nil, fmt.Errorf("ListRoles: %w", err) } @@ -51,8 +52,8 @@ func (d *DB) ListRoles() ([]*Role, error) { // Unlike GetUserWithRole, this does not fetch sensitive user columns (password, // TOTP secret). Use this on hot paths like permission checks. // Returns (nil, nil) when the user is not found. -func (d *DB) GetRoleForUser(userID int64) (*Role, error) { - r, err := d.q.GetRoleForUser(dbCtx(), userID) +func (d *DB) GetRoleForUser(ctx context.Context, userID int64) (*Role, error) { + r, err := d.q.GetRoleForUser(ctx, userID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -64,8 +65,8 @@ func (d *DB) GetRoleForUser(userID int64) (*Role, error) { // GetUserWithRole returns the user and their role in a single query. // Returns (nil, nil, nil) when the user is not found. -func (d *DB) GetUserWithRole(userID int64) (*User, *Role, error) { - row := d.sqlDB.QueryRow( +func (d *DB) GetUserWithRole(ctx context.Context, userID int64) (*User, *Role, error) { + row := d.sqlDB.QueryRowContext(ctx, `SELECT u.id, u.username, u.password, u.avatar, u.role_id, u.totp_secret, u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, diff --git a/Server/db/voice_queries.go b/Server/db/voice_queries.go index 7054cec8..775a8efa 100644 --- a/Server/db/voice_queries.go +++ b/Server/db/voice_queries.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "errors" "fmt" @@ -27,8 +28,8 @@ func newVoiceJoinToken() string { // joined_at doubles as an opaque join-instance token so stale cleanup can // target one specific voice session even if the user later rejoins the same // channel. -func (d *DB) JoinVoiceChannel(userID, channelID int64) error { - if err := d.q.JoinVoiceChannel(dbCtx(), dbgen.JoinVoiceChannelParams{ +func (d *DB) JoinVoiceChannel(ctx context.Context, userID, channelID int64) error { + if err := d.q.JoinVoiceChannel(ctx, dbgen.JoinVoiceChannelParams{ UserID: userID, ChannelID: channelID, JoinedAt: newVoiceJoinToken(), @@ -42,8 +43,8 @@ func (d *DB) JoinVoiceChannel(userID, channelID int64) error { // channel has fewer than maxUsers participants. Returns ErrChannelFull when // the channel is at capacity. This prevents the TOCTOU race where two // concurrent joins both observe capacity and both succeed. -func (d *DB) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error { - res, err := d.q.JoinVoiceChannelIfCapacity(dbCtx(), dbgen.JoinVoiceChannelIfCapacityParams{ +func (d *DB) JoinVoiceChannelIfCapacity(ctx context.Context, userID, channelID int64, maxUsers int) error { + res, err := d.q.JoinVoiceChannelIfCapacity(ctx, dbgen.JoinVoiceChannelIfCapacityParams{ UserID: userID, ChannelID: channelID, JoinedAt: newVoiceJoinToken(), @@ -62,8 +63,8 @@ func (d *DB) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) e // LeaveVoiceChannel removes the user's voice state entirely. // It is safe to call when the user is not in any voice channel. -func (d *DB) LeaveVoiceChannel(userID int64) error { - if err := d.q.LeaveVoiceChannel(dbCtx(), userID); err != nil { +func (d *DB) LeaveVoiceChannel(ctx context.Context, userID int64) error { + if err := d.q.LeaveVoiceChannel(ctx, userID); err != nil { return fmt.Errorf("LeaveVoiceChannel: %w", err) } return nil @@ -72,8 +73,8 @@ func (d *DB) LeaveVoiceChannel(userID int64) error { // LeaveVoiceChannelIfMatch removes the user's voice state only if the row // still points at expectedChannelID and matches the expected join token. // Returns true if a row was deleted. -func (d *DB) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) { - result, err := d.q.LeaveVoiceChannelIfMatch(dbCtx(), dbgen.LeaveVoiceChannelIfMatchParams{ +func (d *DB) LeaveVoiceChannelIfMatch(ctx context.Context, userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) { + result, err := d.q.LeaveVoiceChannelIfMatch(ctx, dbgen.LeaveVoiceChannelIfMatchParams{ UserID: userID, ChannelID: expectedChannelID, JoinedAt: expectedJoinedAt, @@ -87,8 +88,8 @@ func (d *DB) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJ // GetVoiceState returns the current voice state for the given user, // or nil if the user is not in any voice channel. -func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) { - r, err := d.q.GetUserVoiceState(dbCtx(), userID) +func (d *DB) GetVoiceState(ctx context.Context, userID int64) (*VoiceState, error) { + r, err := d.q.GetUserVoiceState(ctx, userID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -111,8 +112,8 @@ func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) { // GetChannelVoiceStates returns all voice states for users currently in the // given voice channel. -func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) { - rows, err := d.q.GetChannelVoiceStates(dbCtx(), channelID) +func (d *DB) GetChannelVoiceStates(ctx context.Context, channelID int64) ([]VoiceState, error) { + rows, err := d.q.GetChannelVoiceStates(ctx, channelID) if err != nil { return nil, fmt.Errorf("GetChannelVoiceStates: %w", err) } @@ -135,8 +136,8 @@ func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) { // GetAllVoiceStates returns voice states across all voice channels in a single // query. Used at startup to build the ready payload without N+1 per-channel queries. -func (d *DB) GetAllVoiceStates() ([]VoiceState, error) { - rows, err := d.q.GetAllVoiceStates(dbCtx()) +func (d *DB) GetAllVoiceStates(ctx context.Context) ([]VoiceState, error) { + rows, err := d.q.GetAllVoiceStates(ctx) if err != nil { return nil, fmt.Errorf("GetAllVoiceStates: %w", err) } @@ -159,8 +160,8 @@ func (d *DB) GetAllVoiceStates() ([]VoiceState, error) { // UpdateVoiceMute sets the muted field for the given user's voice state. // It is safe to call when the user is not in any channel (no-op). -func (d *DB) UpdateVoiceMute(userID int64, muted bool) error { - if err := d.q.UpdateVoiceMute(dbCtx(), dbgen.UpdateVoiceMuteParams{ +func (d *DB) UpdateVoiceMute(ctx context.Context, userID int64, muted bool) error { + if err := d.q.UpdateVoiceMute(ctx, dbgen.UpdateVoiceMuteParams{ Muted: b2i64(muted), UserID: userID, }); err != nil { @@ -171,8 +172,8 @@ func (d *DB) UpdateVoiceMute(userID int64, muted bool) error { // UpdateVoiceDeafen sets the deafened field for the given user's voice state. // It is safe to call when the user is not in any channel (no-op). -func (d *DB) UpdateVoiceDeafen(userID int64, deafened bool) error { - if err := d.q.UpdateVoiceDeafen(dbCtx(), dbgen.UpdateVoiceDeafenParams{ +func (d *DB) UpdateVoiceDeafen(ctx context.Context, userID int64, deafened bool) error { + if err := d.q.UpdateVoiceDeafen(ctx, dbgen.UpdateVoiceDeafenParams{ Deafened: b2i64(deafened), UserID: userID, }); err != nil { @@ -183,8 +184,8 @@ func (d *DB) UpdateVoiceDeafen(userID int64, deafened bool) error { // ClearVoiceState removes a user's voice state on disconnect. // Equivalent to LeaveVoiceChannel but named to clarify the disconnect use case. -func (d *DB) ClearVoiceState(userID int64) error { - if err := d.q.ClearVoiceState(dbCtx(), userID); err != nil { +func (d *DB) ClearVoiceState(ctx context.Context, userID int64) error { + if err := d.q.ClearVoiceState(ctx, userID); err != nil { return fmt.Errorf("ClearVoiceState: %w", err) } return nil @@ -192,8 +193,8 @@ func (d *DB) ClearVoiceState(userID int64) error { // ClearAllVoiceStates removes all voice state rows. Called on server startup // to clear stale state from a previous run. -func (d *DB) ClearAllVoiceStates() error { - if err := d.q.ClearAllVoiceStates(dbCtx()); err != nil { +func (d *DB) ClearAllVoiceStates(ctx context.Context) error { + if err := d.q.ClearAllVoiceStates(ctx); err != nil { return fmt.Errorf("ClearAllVoiceStates: %w", err) } return nil @@ -202,8 +203,8 @@ func (d *DB) ClearAllVoiceStates() error { // CountActiveCameras returns the number of users with camera enabled in the // given voice channel. Uses the DB as source of truth (race-free via SQLite // serialization) rather than querying LiveKit. -func (d *DB) CountActiveCameras(channelID int64) (int, error) { - count, err := d.q.CountActiveCameras(dbCtx(), channelID) +func (d *DB) CountActiveCameras(ctx context.Context, channelID int64) (int, error) { + count, err := d.q.CountActiveCameras(ctx, channelID) if err != nil { return 0, fmt.Errorf("CountActiveCameras: %w", err) } @@ -211,8 +212,8 @@ func (d *DB) CountActiveCameras(channelID int64) (int, error) { } // UpdateVoiceCamera sets the camera field for the given user's voice state. -func (d *DB) UpdateVoiceCamera(userID int64, camera bool) error { - if err := d.q.UpdateVoiceCamera(dbCtx(), dbgen.UpdateVoiceCameraParams{ +func (d *DB) UpdateVoiceCamera(ctx context.Context, userID int64, camera bool) error { + if err := d.q.UpdateVoiceCamera(ctx, dbgen.UpdateVoiceCameraParams{ Camera: b2i64(camera), UserID: userID, }); err != nil { @@ -224,8 +225,8 @@ func (d *DB) UpdateVoiceCamera(userID int64, camera bool) error { // EnableCameraIfUnderLimit atomically enables a user's camera only if the // channel has not yet reached maxVideo active cameras. Returns true if the // camera was enabled, false if the limit was already reached. -func (d *DB) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) { - res, err := d.q.EnableCameraIfUnderLimit(dbCtx(), dbgen.EnableCameraIfUnderLimitParams{ +func (d *DB) EnableCameraIfUnderLimit(ctx context.Context, userID, channelID int64, maxVideo int) (bool, error) { + res, err := d.q.EnableCameraIfUnderLimit(ctx, dbgen.EnableCameraIfUnderLimitParams{ UserID: userID, ChannelID: channelID, ChannelID_2: channelID, @@ -242,8 +243,8 @@ func (d *DB) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bo } // UpdateVoiceScreenshare sets the screenshare field for the given user's voice state. -func (d *DB) UpdateVoiceScreenshare(userID int64, screenshare bool) error { - if err := d.q.UpdateVoiceScreenshare(dbCtx(), dbgen.UpdateVoiceScreenshareParams{ +func (d *DB) UpdateVoiceScreenshare(ctx context.Context, userID int64, screenshare bool) error { + if err := d.q.UpdateVoiceScreenshare(ctx, dbgen.UpdateVoiceScreenshareParams{ Screenshare: b2i64(screenshare), UserID: userID, }); err != nil { @@ -254,9 +255,9 @@ func (d *DB) UpdateVoiceScreenshare(userID int64, screenshare bool) error { // CountChannelVoiceUsers returns the number of users currently in the given // voice channel. -func (d *DB) CountChannelVoiceUsers(channelID int64) (int, error) { +func (d *DB) CountChannelVoiceUsers(ctx context.Context, channelID int64) (int, error) { var count int - err := d.sqlDB.QueryRow( + err := d.sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM voice_states WHERE channel_id = ?`, channelID, ).Scan(&count) diff --git a/Server/db/voice_queries_test.go b/Server/db/voice_queries_test.go index 159e4c88..7fc94d3b 100644 --- a/Server/db/voice_queries_test.go +++ b/Server/db/voice_queries_test.go @@ -1,6 +1,7 @@ package db_test import ( + "context" "testing" "testing/fstest" @@ -60,7 +61,7 @@ CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); // seedVoiceUser creates a user and returns its ID. func seedVoiceUser(t *testing.T, database *db.DB, username string) int64 { t.Helper() - id, err := database.CreateUser(username, "hash", 4) + id, err := database.CreateUser(context.Background(), username, "hash", 4) if err != nil { t.Fatalf("seedVoiceUser: %v", err) } @@ -70,7 +71,7 @@ func seedVoiceUser(t *testing.T, database *db.DB, username string) int64 { // seedVoiceChannel creates a voice-type channel and returns its ID. func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "voice", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) if err != nil { t.Fatalf("seedVoiceChannel: %v", err) } @@ -84,11 +85,11 @@ func TestVoice_JoinVoiceChannel_Success(t *testing.T) { userID := seedVoiceUser(t, database, "alice") chanID := seedVoiceChannel(t, database, "general-voice") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -115,15 +116,15 @@ func TestVoice_JoinVoiceChannel_ReplacesExistingState(t *testing.T) { chan1 := seedVoiceChannel(t, database, "voice-1") chan2 := seedVoiceChannel(t, database, "voice-2") - if err := database.JoinVoiceChannel(userID, chan1); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chan1); err != nil { t.Fatalf("first JoinVoiceChannel: %v", err) } // Join a different channel — should replace the old state. - if err := database.JoinVoiceChannel(userID, chan2); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chan2); err != nil { t.Fatalf("second JoinVoiceChannel: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -140,11 +141,11 @@ func TestVoice_JoinVoiceChannel_SameChannel_Idempotent(t *testing.T) { userID := seedVoiceUser(t, database, "carol") chanID := seedVoiceChannel(t, database, "voice-same") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("first join: %v", err) } // Joining same channel again should not error. - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("second join same channel: %v", err) } } @@ -156,14 +157,14 @@ func TestVoice_LeaveVoiceChannel_ClearsState(t *testing.T) { userID := seedVoiceUser(t, database, "dave") chanID := seedVoiceChannel(t, database, "voice-leave") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.LeaveVoiceChannel(userID); err != nil { + if err := database.LeaveVoiceChannel(context.Background(), userID); err != nil { t.Fatalf("LeaveVoiceChannel: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState after leave: %v", err) } @@ -177,7 +178,7 @@ func TestVoice_LeaveVoiceChannel_NoState_NoError(t *testing.T) { userID := seedVoiceUser(t, database, "eve") // Leaving when not in any channel should not error. - if err := database.LeaveVoiceChannel(userID); err != nil { + if err := database.LeaveVoiceChannel(context.Background(), userID); err != nil { t.Fatalf("LeaveVoiceChannel (not in channel): %v", err) } } @@ -188,7 +189,7 @@ func TestVoice_GetVoiceState_NotFound(t *testing.T) { database := newVoiceTestDB(t) userID := seedVoiceUser(t, database, "frank") - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(not found): %v", err) } @@ -202,11 +203,11 @@ func TestVoice_GetVoiceState_IncludesUsername(t *testing.T) { userID := seedVoiceUser(t, database, "grace") chanID := seedVoiceChannel(t, database, "voice-username") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -224,7 +225,7 @@ func TestVoice_GetChannelVoiceStates_Empty(t *testing.T) { database := newVoiceTestDB(t) chanID := seedVoiceChannel(t, database, "empty-voice") - states, err := database.GetChannelVoiceStates(chanID) + states, err := database.GetChannelVoiceStates(context.Background(), chanID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -241,18 +242,18 @@ func TestVoice_GetChannelVoiceStates_MultipleUsers(t *testing.T) { chanID := seedVoiceChannel(t, database, "multi-voice") otherChan := seedVoiceChannel(t, database, "other-voice") - if err := database.JoinVoiceChannel(u1, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil { t.Fatalf("join u1: %v", err) } - if err := database.JoinVoiceChannel(u2, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u2, chanID); err != nil { t.Fatalf("join u2: %v", err) } // u3 joins a different channel — should not appear. - if err := database.JoinVoiceChannel(u3, otherChan); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u3, otherChan); err != nil { t.Fatalf("join u3: %v", err) } - states, err := database.GetChannelVoiceStates(chanID) + states, err := database.GetChannelVoiceStates(context.Background(), chanID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -275,14 +276,14 @@ func TestVoice_UpdateVoiceMute_True(t *testing.T) { userID := seedVoiceUser(t, database, "kate") chanID := seedVoiceChannel(t, database, "voice-mute") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceMute(userID, true); err != nil { + if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceMute(true): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || !state.Muted { t.Error("Muted = false after UpdateVoiceMute(true)") } @@ -293,17 +294,17 @@ func TestVoice_UpdateVoiceMute_False(t *testing.T) { userID := seedVoiceUser(t, database, "leo") chanID := seedVoiceChannel(t, database, "voice-unmute") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceMute(userID, true); err != nil { + if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceMute(true): %v", err) } - if err := database.UpdateVoiceMute(userID, false); err != nil { + if err := database.UpdateVoiceMute(context.Background(), userID, false); err != nil { t.Fatalf("UpdateVoiceMute(false): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || state.Muted { t.Error("Muted = true after UpdateVoiceMute(false), want false") } @@ -314,7 +315,7 @@ func TestVoice_UpdateVoiceMute_NotInChannel_NoError(t *testing.T) { userID := seedVoiceUser(t, database, "mia") // Muting when not in a channel should not error. - if err := database.UpdateVoiceMute(userID, true); err != nil { + if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceMute for non-member: %v", err) } } @@ -326,14 +327,14 @@ func TestVoice_UpdateVoiceDeafen_True(t *testing.T) { userID := seedVoiceUser(t, database, "noah") chanID := seedVoiceChannel(t, database, "voice-deafen") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceDeafen(userID, true); err != nil { + if err := database.UpdateVoiceDeafen(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceDeafen(true): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || !state.Deafened { t.Error("Deafened = false after UpdateVoiceDeafen(true)") } @@ -344,17 +345,17 @@ func TestVoice_UpdateVoiceDeafen_False(t *testing.T) { userID := seedVoiceUser(t, database, "olivia") chanID := seedVoiceChannel(t, database, "voice-undeafen") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceDeafen(userID, true); err != nil { + if err := database.UpdateVoiceDeafen(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceDeafen(true): %v", err) } - if err := database.UpdateVoiceDeafen(userID, false); err != nil { + if err := database.UpdateVoiceDeafen(context.Background(), userID, false); err != nil { t.Fatalf("UpdateVoiceDeafen(false): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || state.Deafened { t.Error("Deafened = true after UpdateVoiceDeafen(false), want false") } @@ -367,14 +368,14 @@ func TestVoice_ClearVoiceState_RemovesState(t *testing.T) { userID := seedVoiceUser(t, database, "pedro") chanID := seedVoiceChannel(t, database, "voice-clear") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.ClearVoiceState(userID); err != nil { + if err := database.ClearVoiceState(context.Background(), userID); err != nil { t.Fatalf("ClearVoiceState: %v", err) } - state, err := database.GetVoiceState(userID) + state, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState after clear: %v", err) } @@ -387,7 +388,7 @@ func TestVoice_ClearVoiceState_NotInChannel_NoError(t *testing.T) { database := newVoiceTestDB(t) userID := seedVoiceUser(t, database, "quinn") - if err := database.ClearVoiceState(userID); err != nil { + if err := database.ClearVoiceState(context.Background(), userID); err != nil { t.Fatalf("ClearVoiceState for non-member: %v", err) } } @@ -399,11 +400,11 @@ func TestVoice_GetChannelVoiceStates_IncludesUsername(t *testing.T) { u1 := seedVoiceUser(t, database, "rachel") chanID := seedVoiceChannel(t, database, "voice-name-check") - if err := database.JoinVoiceChannel(u1, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - states, err := database.GetChannelVoiceStates(chanID) + states, err := database.GetChannelVoiceStates(context.Background(), chanID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -422,14 +423,14 @@ func TestVoice_UpdateVoiceCamera_True(t *testing.T) { userID := seedVoiceUser(t, database, "cam-on") chanID := seedVoiceChannel(t, database, "voice-camera") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceCamera(userID, true); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceCamera(true): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || !state.Camera { t.Error("Camera = false after UpdateVoiceCamera(true)") } @@ -440,17 +441,17 @@ func TestVoice_UpdateVoiceCamera_False(t *testing.T) { userID := seedVoiceUser(t, database, "cam-off") chanID := seedVoiceChannel(t, database, "voice-camera-off") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceCamera(userID, true); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceCamera(true): %v", err) } - if err := database.UpdateVoiceCamera(userID, false); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, false); err != nil { t.Fatalf("UpdateVoiceCamera(false): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || state.Camera { t.Error("Camera = true after UpdateVoiceCamera(false), want false") } @@ -460,7 +461,7 @@ func TestVoice_UpdateVoiceCamera_NotInChannel_NoError(t *testing.T) { database := newVoiceTestDB(t) userID := seedVoiceUser(t, database, "cam-noop") - if err := database.UpdateVoiceCamera(userID, true); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceCamera for non-member: %v", err) } } @@ -472,14 +473,14 @@ func TestVoice_UpdateVoiceScreenshare_True(t *testing.T) { userID := seedVoiceUser(t, database, "share-on") chanID := seedVoiceChannel(t, database, "voice-screen") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceScreenshare(true): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || !state.Screenshare { t.Error("Screenshare = false after UpdateVoiceScreenshare(true)") } @@ -490,17 +491,17 @@ func TestVoice_UpdateVoiceScreenshare_False(t *testing.T) { userID := seedVoiceUser(t, database, "share-off") chanID := seedVoiceChannel(t, database, "voice-screen-off") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceScreenshare(true): %v", err) } - if err := database.UpdateVoiceScreenshare(userID, false); err != nil { + if err := database.UpdateVoiceScreenshare(context.Background(), userID, false); err != nil { t.Fatalf("UpdateVoiceScreenshare(false): %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil || state.Screenshare { t.Error("Screenshare = true after UpdateVoiceScreenshare(false), want false") } @@ -512,7 +513,7 @@ func TestVoice_CountChannelVoiceUsers_Empty(t *testing.T) { database := newVoiceTestDB(t) chanID := seedVoiceChannel(t, database, "count-empty") - count, err := database.CountChannelVoiceUsers(chanID) + count, err := database.CountChannelVoiceUsers(context.Background(), chanID) if err != nil { t.Fatalf("CountChannelVoiceUsers: %v", err) } @@ -529,18 +530,18 @@ func TestVoice_CountChannelVoiceUsers_Multiple(t *testing.T) { chanID := seedVoiceChannel(t, database, "count-multi") otherChan := seedVoiceChannel(t, database, "count-other") - if err := database.JoinVoiceChannel(u1, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil { t.Fatalf("join u1: %v", err) } - if err := database.JoinVoiceChannel(u2, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u2, chanID); err != nil { t.Fatalf("join u2: %v", err) } // u3 joins a different channel — should not be counted. - if err := database.JoinVoiceChannel(u3, otherChan); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u3, otherChan); err != nil { t.Fatalf("join u3: %v", err) } - count, err := database.CountChannelVoiceUsers(chanID) + count, err := database.CountChannelVoiceUsers(context.Background(), chanID) if err != nil { t.Fatalf("CountChannelVoiceUsers: %v", err) } @@ -558,19 +559,19 @@ func TestVoice_ClearAllVoiceStates_RemovesAll(t *testing.T) { chan1 := seedVoiceChannel(t, database, "clear-ch1") chan2 := seedVoiceChannel(t, database, "clear-ch2") - if err := database.JoinVoiceChannel(u1, chan1); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1, chan1); err != nil { t.Fatalf("join u1: %v", err) } - if err := database.JoinVoiceChannel(u2, chan2); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u2, chan2); err != nil { t.Fatalf("join u2: %v", err) } - if err := database.ClearAllVoiceStates(); err != nil { + if err := database.ClearAllVoiceStates(context.Background()); err != nil { t.Fatalf("ClearAllVoiceStates: %v", err) } - s1, _ := database.GetVoiceState(u1) - s2, _ := database.GetVoiceState(u2) + s1, _ := database.GetVoiceState(context.Background(), u1) + s2, _ := database.GetVoiceState(context.Background(), u2) if s1 != nil || s2 != nil { t.Error("voice states still exist after ClearAllVoiceStates") } @@ -579,7 +580,7 @@ func TestVoice_ClearAllVoiceStates_RemovesAll(t *testing.T) { func TestVoice_ClearAllVoiceStates_EmptyTable_NoError(t *testing.T) { database := newVoiceTestDB(t) - if err := database.ClearAllVoiceStates(); err != nil { + if err := database.ClearAllVoiceStates(context.Background()); err != nil { t.Fatalf("ClearAllVoiceStates on empty table: %v", err) } } @@ -593,22 +594,22 @@ func TestVoice_JoinVoiceChannel_ResetsCameraAndScreenshare(t *testing.T) { chan2 := seedVoiceChannel(t, database, "voice-reset2") // Join, enable camera and screenshare. - if err := database.JoinVoiceChannel(userID, chan1); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chan1); err != nil { t.Fatalf("first join: %v", err) } - if err := database.UpdateVoiceCamera(userID, true); err != nil { + if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceCamera: %v", err) } - if err := database.UpdateVoiceScreenshare(userID, true); err != nil { + if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil { t.Fatalf("UpdateVoiceScreenshare: %v", err) } // Join a different channel — camera and screenshare should be reset. - if err := database.JoinVoiceChannel(userID, chan2); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chan2); err != nil { t.Fatalf("second join: %v", err) } - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil { t.Fatal("GetVoiceState returned nil after re-join") } @@ -627,12 +628,12 @@ func TestVoice_GetVoiceState_IncludesCameraAndScreenshare(t *testing.T) { userID := seedVoiceUser(t, database, "av-fields") chanID := seedVoiceChannel(t, database, "voice-av-fields") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } // Initially both should be false. - state, _ := database.GetVoiceState(userID) + state, _ := database.GetVoiceState(context.Background(), userID) if state == nil { t.Fatal("GetVoiceState returned nil") } @@ -644,10 +645,10 @@ func TestVoice_GetVoiceState_IncludesCameraAndScreenshare(t *testing.T) { } // Enable both. - _ = database.UpdateVoiceCamera(userID, true) - _ = database.UpdateVoiceScreenshare(userID, true) + _ = database.UpdateVoiceCamera(context.Background(), userID, true) + _ = database.UpdateVoiceScreenshare(context.Background(), userID, true) - state, _ = database.GetVoiceState(userID) + state, _ = database.GetVoiceState(context.Background(), userID) if state == nil { t.Fatal("GetVoiceState returned nil after update") } @@ -666,12 +667,12 @@ func TestVoice_GetChannelVoiceStates_IncludesCameraAndScreenshare(t *testing.T) userID := seedVoiceUser(t, database, "chan-av") chanID := seedVoiceChannel(t, database, "voice-chan-av") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - _ = database.UpdateVoiceCamera(userID, true) + _ = database.UpdateVoiceCamera(context.Background(), userID, true) - states, err := database.GetChannelVoiceStates(chanID) + states, err := database.GetChannelVoiceStates(context.Background(), chanID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -691,10 +692,10 @@ func TestVoice_JoinVoiceChannel_SameChannel_RefreshesJoinToken(t *testing.T) { userID := seedVoiceUser(t, database, "same-channel-token") chanID := seedVoiceChannel(t, database, "voice-same-token") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("first JoinVoiceChannel: %v", err) } - first, err := database.GetVoiceState(userID) + first, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(first): %v", err) } @@ -702,10 +703,10 @@ func TestVoice_JoinVoiceChannel_SameChannel_RefreshesJoinToken(t *testing.T) { t.Fatal("first join token missing") } - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("second JoinVoiceChannel: %v", err) } - second, err := database.GetVoiceState(userID) + second, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(second): %v", err) } @@ -722,10 +723,10 @@ func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testin userID := seedVoiceUser(t, database, "stale-delete") chanID := seedVoiceChannel(t, database, "voice-stale-delete") - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("first JoinVoiceChannel: %v", err) } - first, err := database.GetVoiceState(userID) + first, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(first): %v", err) } @@ -733,10 +734,10 @@ func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testin t.Fatal("GetVoiceState(first) returned nil") } - if err := database.JoinVoiceChannel(userID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil { t.Fatalf("second JoinVoiceChannel: %v", err) } - second, err := database.GetVoiceState(userID) + second, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(second): %v", err) } @@ -744,7 +745,7 @@ func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testin t.Fatal("GetVoiceState(second) returned nil") } - deleted, err := database.LeaveVoiceChannelIfMatch(userID, chanID, first.JoinedAt) + deleted, err := database.LeaveVoiceChannelIfMatch(context.Background(), userID, chanID, first.JoinedAt) if err != nil { t.Fatalf("LeaveVoiceChannelIfMatch: %v", err) } @@ -752,7 +753,7 @@ func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testin t.Fatal("stale join token deleted the replacement same-channel row") } - current, err := database.GetVoiceState(userID) + current, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(current): %v", err) } diff --git a/Server/main.go b/Server/main.go index 05f75400..f55821a0 100644 --- a/Server/main.go +++ b/Server/main.go @@ -51,9 +51,9 @@ func main() { // run is the real entrypoint — separated for testability. func run(log *slog.Logger, logBuf *admin.RingBuffer) error { // bgCtx is a cancellable context shared by all background goroutines - // (event persister, event pruner, plugin loader). It is cancelled - // early in the shutdown sequence so in-flight DB operations do not - // block after the database is being torn down. + // (event persister, event pruner, plugin loader, maintenance loop). + // It is cancelled early in the shutdown sequence so in-flight DB + // operations do not block after the database is being torn down. bgCtx, bgCancel := context.WithCancel(context.Background()) defer bgCancel() @@ -111,13 +111,14 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { return fmt.Errorf("running migrations: %w", err) } - // Clear stale state from a previous run or crash. - if err := database.ResetAllUserStatuses(); err != nil { + // Clear stale state from a previous run or crash. Startup work — nothing + // to inherit a context from yet. + if err := database.ResetAllUserStatuses(context.Background()); err != nil { log.Warn("failed to reset stale user statuses", "error", err) } else { log.Info("reset all user statuses to offline") } - if err := database.ClearAllVoiceStates(); err != nil { + if err := database.ClearAllVoiceStates(context.Background()); err != nil { log.Warn("failed to clear stale voice states", "error", err) } else { log.Info("cleared stale voice states") @@ -262,14 +263,14 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { } tickFailed := false - if err := database.DeleteExpiredSessions(); err != nil { + if err := database.DeleteExpiredSessions(bgCtx); err != nil { log.Warn("failed to delete expired sessions", "error", err) tickFailed = true } // Clean up orphaned attachments (uploaded but never linked to a message). cutoff := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) - orphanFiles, orphanErr := database.DeleteOrphanedAttachments(cutoff) + orphanFiles, orphanErr := database.DeleteOrphanedAttachments(bgCtx, cutoff) if orphanErr != nil { log.Warn("failed to delete orphaned attachments", "error", orphanErr) tickFailed = true diff --git a/Server/permissions/checker.go b/Server/permissions/checker.go index 7e1d21e5..7c174781 100644 --- a/Server/permissions/checker.go +++ b/Server/permissions/checker.go @@ -1,6 +1,7 @@ package permissions import ( + "context" "errors" "fmt" ) @@ -32,8 +33,8 @@ type ChannelRef struct { // DB is the minimal database interface the Checker needs. // Defined at the consumer (per Go convention: accept interfaces, return structs). type DB interface { - GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) - IsDMParticipant(userID, channelID int64) (bool, error) + GetChannelPermissions(ctx context.Context, channelID, roleID int64) (allow, deny int64, err error) + IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) } // ─── Checker ──────────────────────────────────────────────────────────────── @@ -53,11 +54,11 @@ func NewChecker(db DB) *Checker { // has all the given permission bits on the specified channel. Administrator // roles bypass all checks. Channel overrides (allow/deny) are fetched from the // database per call. -func (ck *Checker) HasChannelPerm(rolePerms int64, roleID, channelID, perm int64) bool { +func (ck *Checker) HasChannelPerm(ctx context.Context, rolePerms int64, roleID, channelID, perm int64) bool { if HasAdmin(rolePerms) { return true } - allow, deny, err := ck.db.GetChannelPermissions(channelID, roleID) + allow, deny, err := ck.db.GetChannelPermissions(ctx, channelID, roleID) if err != nil { return false } @@ -105,9 +106,9 @@ func (ck *Checker) VisibleChannelIDs(rolePerms int64, channels []ChannelRef, ove // role-based permissions via HasChannelPerm. // // Returns nil on success, or a descriptive error on failure. -func (ck *Checker) RequireChannelAccess(userID, rolePerms, roleID int64, channelType string, channelID, perm int64) error { +func (ck *Checker) RequireChannelAccess(ctx context.Context, userID, rolePerms, roleID int64, channelType string, channelID, perm int64) error { if channelType == "dm" { - ok, err := ck.db.IsDMParticipant(userID, channelID) + ok, err := ck.db.IsDMParticipant(ctx, userID, channelID) if err != nil { return fmt.Errorf("checking DM participation: %w", err) } @@ -117,7 +118,7 @@ func (ck *Checker) RequireChannelAccess(userID, rolePerms, roleID int64, channel return nil } - if !ck.HasChannelPerm(rolePerms, roleID, channelID, perm) { + if !ck.HasChannelPerm(ctx, rolePerms, roleID, channelID, perm) { return ErrPermissionDenied } return nil diff --git a/Server/permissions/checker_test.go b/Server/permissions/checker_test.go index ab566433..da78fdd0 100644 --- a/Server/permissions/checker_test.go +++ b/Server/permissions/checker_test.go @@ -1,6 +1,7 @@ package permissions import ( + "context" "errors" "testing" ) @@ -27,7 +28,7 @@ func newMockDB() *mockDB { } } -func (m *mockDB) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) { +func (m *mockDB) GetChannelPermissions(_ context.Context, channelID, roleID int64) (int64, int64, error) { if m.chanErr != nil { return 0, 0, m.chanErr } @@ -39,7 +40,7 @@ func (m *mockDB) GetChannelPermissions(channelID, roleID int64) (int64, int64, e return p.allow, p.deny, nil } -func (m *mockDB) IsDMParticipant(userID, channelID int64) (bool, error) { +func (m *mockDB) IsDMParticipant(_ context.Context, userID, channelID int64) (bool, error) { if m.dmErr != nil { return false, m.dmErr } @@ -125,7 +126,7 @@ func TestHasChannelPerm(t *testing.T) { } ck := NewChecker(db) - got := ck.HasChannelPerm(tt.rolePerms, tt.roleID, tt.channelID, tt.perm) + got := ck.HasChannelPerm(context.Background(), tt.rolePerms, tt.roleID, tt.channelID, tt.perm) if got != tt.want { t.Errorf("HasChannelPerm() = %v, want %v", got, tt.want) } @@ -379,7 +380,7 @@ func TestRequireChannelAccess(t *testing.T) { } ck := NewChecker(db) - err := ck.RequireChannelAccess(tt.userID, tt.rolePerms, tt.roleID, tt.channelType, tt.channelID, tt.perm) + err := ck.RequireChannelAccess(context.Background(), tt.userID, tt.rolePerms, tt.roleID, tt.channelType, tt.channelID, tt.perm) if tt.dmErr != nil { // Expect wrapped error. diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index 6cf708df..0ea86bb9 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -112,7 +112,7 @@ func NewRegistry(cfg Config) (*Registry, error) { func (r *Registry) Close(ctx context.Context) error { r.mu.Lock() for _, inst := range r.plugins { - r.platformDeactivate(inst) + r.platformDeactivate(ctx, inst) } for id := range r.plugins { delete(r.plugins, id) @@ -192,7 +192,7 @@ func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error // blocked the fresh instance from re-registering its own commands and // kept dispatch routing into the orphaned old module until restart. if old := r.byName[found.Manifest.Name]; old != nil { - r.platformDeactivate(old) + r.platformDeactivate(ctx, old) for cmd, owner := range r.commands { if owner == old { delete(r.commands, cmd) @@ -495,7 +495,7 @@ func (r *Registry) DisablePlugin(ctx context.Context, id int64) error { // Free the wazero module so memory is returned to the runtime // immediately rather than waiting for registry Close. Safe to call // on an instance that was never activated. - r.platformDeactivate(inst) + r.platformDeactivate(ctx, inst) } return nil } diff --git a/Server/plugin/sandbox_default.go b/Server/plugin/sandbox_default.go index be4a0f67..acc9c2df 100644 --- a/Server/plugin/sandbox_default.go +++ b/Server/plugin/sandbox_default.go @@ -24,7 +24,7 @@ func (r *Registry) activateWithRuntime(_ context.Context, _ any, _ *Instance) er } // platformDeactivate is called from Close on each plugin; a no-op here. -func (r *Registry) platformDeactivate(_ *Instance) {} +func (r *Registry) platformDeactivate(_ context.Context, _ *Instance) {} // invokeCommand returns an error result instructing the operator to enable // the wazero build tag. Default build only. diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index fc73637e..4decc103 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -159,13 +159,14 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * // platformDeactivate closes the wazero module held by inst without touching // the shared runtime. Safe to call on an instance that was never activated. -// Called from DisablePlugin and Close. -func (r *Registry) platformDeactivate(inst *Instance) { +// Called from DisablePlugin and Close. Module teardown must run to completion +// once started, so the caller's cancellation is detached (WithoutCancel). +func (r *Registry) platformDeactivate(ctx context.Context, inst *Instance) { if inst == nil || inst.module == nil { return } if mod, ok := inst.module.(api.Module); ok { - _ = mod.Close(context.Background()) + _ = mod.Close(context.WithoutCancel(ctx)) } inst.module = nil } diff --git a/Server/scripts/seed.go b/Server/scripts/seed.go index fd23c502..a74651a0 100644 --- a/Server/scripts/seed.go +++ b/Server/scripts/seed.go @@ -9,6 +9,7 @@ package main import ( + "context" "flag" "fmt" "log" @@ -190,7 +191,7 @@ func createUsers(database *db.DB) ([]int64, error) { ids := make([]int64, len(seedUsers)) for i, su := range seedUsers { - existing, err := database.GetUserByUsername(su.Username) + existing, err := database.GetUserByUsername(context.Background(), su.Username) if err != nil { return nil, fmt.Errorf("checking user %q: %w", su.Username, err) } @@ -205,7 +206,7 @@ func createUsers(database *db.DB) ([]int64, error) { return nil, fmt.Errorf("hashing password for %q: %w", su.Username, err) } - id, err := database.CreateUser(su.Username, hash, su.RoleID) + id, err := database.CreateUser(context.Background(), su.Username, hash, su.RoleID) if err != nil { return nil, fmt.Errorf("creating user %q: %w", su.Username, err) } @@ -240,7 +241,7 @@ func createChannels(database *db.DB) ([]int64, error) { ids := make([]int64, len(seedChannels)) // Fetch existing channels once to check for duplicates. - existing, err := database.ListChannels() + existing, err := database.ListChannels(context.Background()) if err != nil { return nil, fmt.Errorf("listing channels: %w", err) } @@ -256,7 +257,7 @@ func createChannels(database *db.DB) ([]int64, error) { continue } - id, err := database.CreateChannel(sc.Name, sc.Type, sc.Category, sc.Topic, sc.Position) + id, err := database.CreateChannel(context.Background(), sc.Name, sc.Type, sc.Category, sc.Topic, sc.Position) if err != nil { return nil, fmt.Errorf("creating channel %q: %w", sc.Name, err) } @@ -286,7 +287,7 @@ func createMessages(database *db.DB, channelIDs, userIDs []int64) (int, error) { continue } - if _, err := database.CreateMessage(channelID, userID, sm.Content, nil); err != nil { + if _, err := database.CreateMessage(context.Background(), channelID, userID, sm.Content, nil); err != nil { return 0, fmt.Errorf("creating message in channel %d: %w", channelID, err) } created++ @@ -305,7 +306,7 @@ func createMessages(database *db.DB, channelIDs, userIDs []int64) (int, error) { // user already exists in the channel. Used for idempotency. func messageExists(database *db.DB, channelID, userID int64, content string) (bool, error) { var count int - err := database.QueryRow( + err := database.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM messages WHERE channel_id = ? AND user_id = ? AND content = ? AND deleted = 0`, channelID, userID, content, ).Scan(&count) @@ -321,7 +322,7 @@ func createDMConversation(database *db.DB, userIDs []int64) (int, error) { adminID := userIDs[uAdmin] aliceID := userIDs[uAlice] - ch, isNew, err := database.GetOrCreateDMChannel(adminID, aliceID) + ch, isNew, err := database.GetOrCreateDMChannel(context.Background(), adminID, aliceID) if err != nil { return 0, fmt.Errorf("creating DM channel: %w", err) } @@ -344,7 +345,7 @@ func createDMConversation(database *db.DB, userIDs []int64) (int, error) { continue } - if _, err := database.CreateMessage(ch.ID, senderID, dm.Content, nil); err != nil { + if _, err := database.CreateMessage(context.Background(), ch.ID, senderID, dm.Content, nil); err != nil { return 0, fmt.Errorf("creating DM message: %w", err) } created++ diff --git a/Server/service/block.go b/Server/service/block.go index f57988d1..24c4eef9 100644 --- a/Server/service/block.go +++ b/Server/service/block.go @@ -40,12 +40,12 @@ func (s *BlockService) BlockUser(ctx context.Context, blockerID, targetID int64) return fmt.Errorf("%w: cannot block yourself", ErrBadRequest) } - target, err := s.st.GetUserByID(targetID) + target, err := s.st.GetUserByID(ctx, targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } - if err := s.st.BlockUser(blockerID, targetID); err != nil { + if err := s.st.BlockUser(ctx, blockerID, targetID); err != nil { return fmt.Errorf("%w: failed to block user", ErrInternal) } @@ -54,11 +54,11 @@ func (s *BlockService) BlockUser(ctx context.Context, blockerID, targetID int64) } // UnblockUser removes a block on a target user. -func (s *BlockService) UnblockUser(blockerID, targetID int64) error { +func (s *BlockService) UnblockUser(ctx context.Context, blockerID, targetID int64) error { if targetID <= 0 { return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) } - if err := s.st.UnblockUser(blockerID, targetID); err != nil { + if err := s.st.UnblockUser(ctx, blockerID, targetID); err != nil { return fmt.Errorf("%w: failed to unblock user", ErrInternal) } slog.Info("user unblocked", "blocker_id", blockerID, "target_id", targetID) @@ -66,8 +66,8 @@ func (s *BlockService) UnblockUser(blockerID, targetID int64) error { } // ListBlocked returns all user IDs blocked by the given user. -func (s *BlockService) ListBlocked(blockerID int64) ([]int64, error) { - ids, err := s.st.ListBlockedUsers(blockerID) +func (s *BlockService) ListBlocked(ctx context.Context, blockerID int64) ([]int64, error) { + ids, err := s.st.ListBlockedUsers(ctx, blockerID) if err != nil { return nil, fmt.Errorf("%w: failed to list blocked users", ErrInternal) } diff --git a/Server/service/channel.go b/Server/service/channel.go index 5692091e..03f39320 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -40,13 +40,13 @@ func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) telemetry.String("method", "ListVisibleChannels")) span.End() }() - all, err := s.st.ListChannels() + all, err := s.st.ListChannels(ctx) if err != nil { slog.Error("ChannelService.ListVisibleChannels", "err", err) return nil, fmt.Errorf("%w: failed to list channels", ErrInternal) } - role, err := s.perms.GetRoleForUser(userID) + role, err := s.perms.GetRoleForUser(ctx, userID) if err != nil || role == nil { slog.Error("ChannelService.ListVisibleChannels GetRoleForUser", "err", err, "user_id", userID) return nil, fmt.Errorf("%w: failed to get role", ErrInternal) @@ -55,7 +55,7 @@ func (s *ChannelService) ListVisibleChannels(ctx context.Context, userID int64) // Admins skip the override fetch (they bypass all channel checks anyway). var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - overrides, err = s.st.GetAllChannelPermissionsForRole(role.ID) + overrides, err = s.st.GetAllChannelPermissionsForRole(ctx, role.ID) if err != nil { // Fail closed — an empty map would return every denied channel. slog.Error("ChannelService.ListVisibleChannels GetAllChannelPermissionsForRole", "err", err, "user_id", userID, "role_id", role.ID) @@ -96,7 +96,7 @@ func permOverrides(overrides map[int64]db.ChannelOverride) map[int64]permissions // HandleTyping processes a typing start event for a channel. // Returns the channel so callers can build broadcast events. // Silent errors are returned as nil (typing indicators are best-effort). -func (s *ChannelService) HandleTyping(userID, channelID int64, limiter interface { +func (s *ChannelService) HandleTyping(ctx context.Context, userID, channelID int64, limiter interface { Allow(key string, limit int, window time.Duration) bool }, ) (*db.Channel, error) { @@ -110,17 +110,17 @@ func (s *ChannelService) HandleTyping(userID, channelID int64, limiter interface return nil, nil } - ch, err := s.st.GetChannel(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return nil, nil //nolint:nilerr // typing indicators are best-effort; errors silently dropped } if ch.Type == "dm" { - ok, dmErr := s.st.IsDMParticipant(userID, channelID) + ok, dmErr := s.st.IsDMParticipant(ctx, userID, channelID) if dmErr != nil || !ok { return nil, nil //nolint:nilerr // typing indicators are best-effort; errors silently dropped } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return nil, nil // silent drop } @@ -129,12 +129,12 @@ func (s *ChannelService) HandleTyping(userID, channelID int64, limiter interface // GetDMParticipantIDs returns the participant IDs for a DM channel. // Convenience method for handlers building DM events. -func (s *ChannelService) GetDMParticipantIDs(channelID int64) ([]int64, error) { - return s.st.GetDMParticipantIDs(channelID) +func (s *ChannelService) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) { + return s.st.GetDMParticipantIDs(ctx, channelID) } // HandlePresenceUpdate validates and persists a presence status change. -func (s *ChannelService) HandlePresenceUpdate(userID int64, status string, limiter interface { +func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64, status string, limiter interface { Allow(key string, limit int, window time.Duration) bool }, ) error { @@ -151,7 +151,7 @@ func (s *ChannelService) HandlePresenceUpdate(userID int64, status string, limit return fmt.Errorf("%w: invalid status", ErrBadRequest) } - if err := s.st.UpdateUserStatus(userID, status); err != nil { + if err := s.st.UpdateUserStatus(ctx, userID, status); err != nil { slog.Error("ChannelService.HandlePresenceUpdate", "err", err, "user_id", userID) return fmt.Errorf("%w: failed to update status", ErrInternal) } @@ -161,29 +161,29 @@ func (s *ChannelService) HandlePresenceUpdate(userID int64, status string, limit // HandleChannelFocus processes a channel focus event and updates read state. // Returns the channel for callers to set client state. -func (s *ChannelService) HandleChannelFocus(userID, channelID int64) (*db.Channel, error) { +func (s *ChannelService) HandleChannelFocus(ctx context.Context, userID, channelID int64) (*db.Channel, error) { if channelID <= 0 { return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } - ch, err := s.st.GetChannel(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } // Mark channel as read. - latestID, err := s.st.GetLatestMessageID(channelID) + latestID, err := s.st.GetLatestMessageID(ctx, channelID) if err == nil && latestID > 0 { - _ = s.st.UpdateReadState(userID, channelID, latestID) + _ = s.st.UpdateReadState(ctx, userID, channelID, latestID) } slog.Debug("channel_focus", "user_id", userID, "channel_id", channelID) diff --git a/Server/service/datastore.go b/Server/service/datastore.go index f5a8f203..f9458663 100644 --- a/Server/service/datastore.go +++ b/Server/service/datastore.go @@ -18,135 +18,135 @@ import ( // which *db.DB and this Store both satisfy. type Store interface { // ── Messages / reactions / read-state ── - CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) - GetMessage(id int64) (*db.Message, error) - GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error) - GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) - EditMessage(id, userID int64, content string) error - DeleteMessage(id, userID int64, isMod bool) error - SearchMessages(query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) - SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) - GetPinnedMessages(channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) - SetMessagePinned(id int64, pinned bool) error - AddReaction(messageID, userID int64, emoji string) error - RemoveReaction(messageID, userID int64, emoji string) error - GetReactions(messageID int64) ([]db.ReactionCount, error) - UpdateReadState(userID, channelID, lastReadMessageID int64) error - GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) - GetLatestMessageID(channelID int64) (int64, error) - LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) - GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) + CreateMessage(ctx context.Context, channelID, userID int64, content string, replyTo *int64) (int64, error) + GetMessage(ctx context.Context, id int64) (*db.Message, error) + GetMessages(ctx context.Context, channelID, before int64, limit int) ([]db.MessageWithUser, error) + GetMessagesForAPI(ctx context.Context, channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) + EditMessage(ctx context.Context, id, userID int64, content string) error + DeleteMessage(ctx context.Context, id, userID int64, isMod bool) error + SearchMessages(ctx context.Context, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) + SearchMessagesInChannels(ctx context.Context, query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) + GetPinnedMessages(ctx context.Context, channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) + SetMessagePinned(ctx context.Context, id int64, pinned bool) error + AddReaction(ctx context.Context, messageID, userID int64, emoji string) error + RemoveReaction(ctx context.Context, messageID, userID int64, emoji string) error + GetReactions(ctx context.Context, messageID int64) ([]db.ReactionCount, error) + UpdateReadState(ctx context.Context, userID, channelID, lastReadMessageID int64) error + GetChannelUnreadCounts(ctx context.Context, userID int64) (map[int64]db.ChannelUnread, error) + GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) + LinkAttachmentsToMessage(ctx context.Context, messageID, uploaderID int64, attachmentIDs []string) (int64, error) + GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]db.AttachmentInfo, error) // ── Channels ── - ListChannels() ([]db.Channel, error) - GetChannel(id int64) (*db.Channel, error) - CreateChannel(name, chanType, category, topic string, position int) (int64, error) - UpdateChannel(id int64, name, topic string, slowMode int) error - DeleteChannel(id int64) error - SetChannelSlowMode(id int64, slowMode int) error - SetChannelVoiceMaxUsers(id int64, maxUsers int) error - GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) - GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) - GetChannelTypes(ids []int64) (map[int64]string, error) + ListChannels(ctx context.Context) ([]db.Channel, error) + GetChannel(ctx context.Context, id int64) (*db.Channel, error) + CreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) + UpdateChannel(ctx context.Context, id int64, name, topic string, slowMode int) error + DeleteChannel(ctx context.Context, id int64) error + SetChannelSlowMode(ctx context.Context, id int64, slowMode int) error + SetChannelVoiceMaxUsers(ctx context.Context, id int64, maxUsers int) error + GetChannelPermissions(ctx context.Context, channelID, roleID int64) (allow, deny int64, err error) + GetAllChannelPermissionsForRole(ctx context.Context, roleID int64) (map[int64]db.ChannelOverride, error) + GetChannelTypes(ctx context.Context, ids []int64) (map[int64]string, error) // ── Users ── - GetUserByID(id int64) (*db.User, error) - GetUserByUsername(username string) (*db.User, error) - CreateUser(username, passwordHash string, roleID int) (int64, error) - CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) - CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) - UpdateUserProfile(userID int64, username string, avatar *string) error - UpdateUserPassword(userID int64, newPasswordHash string) error - UpdateUserStatus(id int64, status string) error - UpdateUserTOTPSecret(id int64, secret *string) error - UpdateUserRole(userID, roleID int64) error - ResetAllUserStatuses() error + GetUserByID(ctx context.Context, id int64) (*db.User, error) + GetUserByUsername(ctx context.Context, username string) (*db.User, error) + CreateUser(ctx context.Context, username, passwordHash string, roleID int) (int64, error) + CreateOwnerIfEmpty(ctx context.Context, username, passwordHash string, roleID int) (int64, error) + CreateUserWithInvite(ctx context.Context, username, passwordHash string, roleID int, inviteCode string) (int64, error) + UpdateUserProfile(ctx context.Context, userID int64, username string, avatar *string) error + UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error + UpdateUserStatus(ctx context.Context, id int64, status string) error + UpdateUserTOTPSecret(ctx context.Context, id int64, secret *string) error + UpdateUserRole(ctx context.Context, userID, roleID int64) error + ResetAllUserStatuses(ctx context.Context) error DeleteAccount(ctx context.Context, userID int64) error - ListMembers() ([]db.MemberSummary, error) + ListMembers(ctx context.Context) ([]db.MemberSummary, error) // ── Sessions ── - CreateSession(userID int64, tokenHash, device, ip string) (int64, error) - GetSessionByTokenHash(tokenHash string) (*db.Session, error) - GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error) - DeleteSession(tokenHash string) error - DeleteOtherSessions(userID, keepSessionID int64) (int64, error) - DeleteExpiredSessions() error - DeleteSessionByID(sessionID, userID int64) error - TouchSession(tokenHash string) error - ListUserSessions(userID int64) ([]db.Session, error) - ForceLogoutUser(userID int64) error - GetUserSessions(userID int64) ([]db.Session, error) + CreateSession(ctx context.Context, userID int64, tokenHash, device, ip string) (int64, error) + GetSessionByTokenHash(ctx context.Context, tokenHash string) (*db.Session, error) + GetSessionWithBanStatus(ctx context.Context, tokenHash string) (*db.SessionWithBanStatus, error) + DeleteSession(ctx context.Context, tokenHash string) error + DeleteOtherSessions(ctx context.Context, userID, keepSessionID int64) (int64, error) + DeleteExpiredSessions(ctx context.Context) error + DeleteSessionByID(ctx context.Context, sessionID, userID int64) error + TouchSession(ctx context.Context, tokenHash string) error + ListUserSessions(ctx context.Context, userID int64) ([]db.Session, error) + ForceLogoutUser(ctx context.Context, userID int64) error + GetUserSessions(ctx context.Context, userID int64) ([]db.Session, error) // ── Roles ── - GetRoleByID(id int64) (*db.Role, error) - GetRoleForUser(userID int64) (*db.Role, error) - GetUserWithRole(userID int64) (*db.User, *db.Role, error) - ListRoles() ([]*db.Role, error) + GetRoleByID(ctx context.Context, id int64) (*db.Role, error) + GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) + GetUserWithRole(ctx context.Context, userID int64) (*db.User, *db.Role, error) + ListRoles(ctx context.Context) ([]*db.Role, error) // ── Invites ── - CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) - GetInvite(code string) (*db.Invite, error) - ListInvites() ([]*db.Invite, error) - UseInviteAtomic(code string) error - RevokeInvite(code string) error + CreateInvite(ctx context.Context, createdBy int64, maxUses int, expiresAt *time.Time) (string, error) + GetInvite(ctx context.Context, code string) (*db.Invite, error) + ListInvites(ctx context.Context) ([]*db.Invite, error) + UseInviteAtomic(ctx context.Context, code string) error + RevokeInvite(ctx context.Context, code string) error // ── Voice ── - JoinVoiceChannel(userID, channelID int64) error - JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error - LeaveVoiceChannel(userID int64) error - LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) - GetVoiceState(userID int64) (*db.VoiceState, error) - GetChannelVoiceStates(channelID int64) ([]db.VoiceState, error) - GetAllVoiceStates() ([]db.VoiceState, error) - UpdateVoiceMute(userID int64, muted bool) error - UpdateVoiceDeafen(userID int64, deafened bool) error - ClearVoiceState(userID int64) error - ClearAllVoiceStates() error - CountActiveCameras(channelID int64) (int, error) - UpdateVoiceCamera(userID int64, camera bool) error - EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) - UpdateVoiceScreenshare(userID int64, screenshare bool) error - CountChannelVoiceUsers(channelID int64) (int, error) + JoinVoiceChannel(ctx context.Context, userID, channelID int64) error + JoinVoiceChannelIfCapacity(ctx context.Context, userID, channelID int64, maxUsers int) error + LeaveVoiceChannel(ctx context.Context, userID int64) error + LeaveVoiceChannelIfMatch(ctx context.Context, userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) + GetVoiceState(ctx context.Context, userID int64) (*db.VoiceState, error) + GetChannelVoiceStates(ctx context.Context, channelID int64) ([]db.VoiceState, error) + GetAllVoiceStates(ctx context.Context) ([]db.VoiceState, error) + UpdateVoiceMute(ctx context.Context, userID int64, muted bool) error + UpdateVoiceDeafen(ctx context.Context, userID int64, deafened bool) error + ClearVoiceState(ctx context.Context, userID int64) error + ClearAllVoiceStates(ctx context.Context) error + CountActiveCameras(ctx context.Context, channelID int64) (int, error) + UpdateVoiceCamera(ctx context.Context, userID int64, camera bool) error + EnableCameraIfUnderLimit(ctx context.Context, userID, channelID int64, maxVideo int) (bool, error) + UpdateVoiceScreenshare(ctx context.Context, userID int64, screenshare bool) error + CountChannelVoiceUsers(ctx context.Context, channelID int64) (int, error) // ── Direct messages ── - GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error) - GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) - OpenDM(userID, channelID int64) error - CloseDM(userID, channelID int64) error - IsDMParticipant(userID, channelID int64) (bool, error) - GetDMParticipantIDs(channelID int64) ([]int64, error) - GetDMRecipient(channelID, requestingUserID int64) (*db.User, error) + GetOrCreateDMChannel(ctx context.Context, user1ID, user2ID int64) (*db.Channel, bool, error) + GetUserDMChannels(ctx context.Context, userID int64) ([]db.DMChannelInfo, error) + OpenDM(ctx context.Context, userID, channelID int64) error + CloseDM(ctx context.Context, userID, channelID int64) error + IsDMParticipant(ctx context.Context, userID, channelID int64) (bool, error) + GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) + GetDMRecipient(ctx context.Context, channelID, requestingUserID int64) (*db.User, error) // ── Blocks ── - BlockUser(blockerID, blockedID int64) error - UnblockUser(blockerID, blockedID int64) error - IsBlocked(blockerID, blockedID int64) (bool, error) - IsEitherBlocked(userA, userB int64) (bool, error) - ListBlockedUsers(blockerID int64) ([]int64, error) + BlockUser(ctx context.Context, blockerID, blockedID int64) error + UnblockUser(ctx context.Context, blockerID, blockedID int64) error + IsBlocked(ctx context.Context, blockerID, blockedID int64) (bool, error) + IsEitherBlocked(ctx context.Context, userA, userB int64) (bool, error) + ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) // ── Attachments ── - CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error - GetAttachmentByID(id string) (*db.Attachment, error) - GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error) - DeleteOrphanedAttachments(cutoff string) ([]string, error) + CreateAttachment(ctx context.Context, id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error + GetAttachmentByID(ctx context.Context, id string) (*db.Attachment, error) + GetAttachmentWithChannel(ctx context.Context, id string) (*db.AttachmentAccess, error) + DeleteOrphanedAttachments(ctx context.Context, cutoff string) ([]string, error) // ── Admin ── - UserCount() (int64, error) - GetServerStats() (*db.ServerStats, error) - ListAllUsers(limit, offset int) ([]db.UserWithRole, error) - BanUser(id int64, reason string, expires *time.Time) error - UnbanUser(id int64) error - LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error - GetAuditLog(limit, offset int) ([]db.AuditEntry, error) - AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) - AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error - AdminDeleteChannel(id int64) error - BackupTo(path string) error - BackupToSafe(path, safeRoot string) error - CountUsersWithoutTOTP() (int, error) + UserCount(ctx context.Context) (int64, error) + GetServerStats(ctx context.Context) (*db.ServerStats, error) + ListAllUsers(ctx context.Context, limit, offset int) ([]db.UserWithRole, error) + BanUser(ctx context.Context, id int64, reason string, expires *time.Time) error + UnbanUser(ctx context.Context, id int64) error + LogAudit(ctx context.Context, actorID int64, action, targetType string, targetID int64, detail string) error + GetAuditLog(ctx context.Context, limit, offset int) ([]db.AuditEntry, error) + AdminCreateChannel(ctx context.Context, name, chanType, category, topic string, position int) (int64, error) + AdminUpdateChannel(ctx context.Context, id int64, name, topic string, slowMode, position int, archived bool) error + AdminDeleteChannel(ctx context.Context, id int64) error + BackupTo(ctx context.Context, path string) error + BackupToSafe(ctx context.Context, path, safeRoot string) error + CountUsersWithoutTOTP(ctx context.Context) (int, error) // ── Settings ── - GetSetting(key string) (string, error) - SetSetting(key, value string) error - GetAllSettings() (map[string]string, error) + GetSetting(ctx context.Context, key string) (string, error) + SetSetting(ctx context.Context, key, value string) error + GetAllSettings(ctx context.Context) (map[string]string, error) } diff --git a/Server/service/dm.go b/Server/service/dm.go index 89a7d993..526e48f0 100644 --- a/Server/service/dm.go +++ b/Server/service/dm.go @@ -48,12 +48,12 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C return nil, fmt.Errorf("%w: cannot create DM with yourself", ErrBadRequest) } - recipient, err := s.st.GetUserByID(recipientID) + recipient, err := s.st.GetUserByID(ctx, recipientID) if err != nil || recipient == nil { return nil, fmt.Errorf("%w: recipient not found", ErrNotFound) } - blocked, err := s.st.IsEitherBlocked(userID, recipientID) + blocked, err := s.st.IsEitherBlocked(ctx, userID, recipientID) if err != nil { return nil, fmt.Errorf("%w: failed to check block status", ErrInternal) } @@ -61,7 +61,7 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C return nil, fmt.Errorf("%w: cannot create DM — user is blocked", ErrForbidden) } - ch, created, err := s.st.GetOrCreateDMChannel(userID, recipientID) + ch, created, err := s.st.GetOrCreateDMChannel(ctx, userID, recipientID) if err != nil { slog.Error("DMService.CreateDM", "err", err) return nil, fmt.Errorf("%w: failed to create DM channel", ErrInternal) @@ -75,8 +75,8 @@ func (s *DMService) CreateDM(ctx context.Context, userID, recipientID int64) (*C } // ListDMs returns all open DM channels for a user. -func (s *DMService) ListDMs(userID int64) ([]db.DMChannelInfo, error) { - dms, err := s.st.GetUserDMChannels(userID) +func (s *DMService) ListDMs(ctx context.Context, userID int64) ([]db.DMChannelInfo, error) { + dms, err := s.st.GetUserDMChannels(ctx, userID) if err != nil { return nil, fmt.Errorf("%w: failed to list DMs", ErrInternal) } @@ -84,17 +84,17 @@ func (s *DMService) ListDMs(userID int64) ([]db.DMChannelInfo, error) { } // CloseDM closes a DM channel for a user. -func (s *DMService) CloseDM(userID, channelID int64) error { +func (s *DMService) CloseDM(ctx context.Context, userID, channelID int64) error { if channelID <= 0 { return fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return fmt.Errorf("%w: not a participant in this DM", ErrNotFound) } - if err := s.st.CloseDM(userID, channelID); err != nil { + if err := s.st.CloseDM(ctx, userID, channelID); err != nil { return fmt.Errorf("%w: failed to close DM", ErrInternal) } diff --git a/Server/service/invite.go b/Server/service/invite.go index 88a2b87a..6f453d5d 100644 --- a/Server/service/invite.go +++ b/Server/service/invite.go @@ -48,12 +48,12 @@ func (s *InviteService) CreateInvite(ctx context.Context, createdBy int64, maxUs expiresAt = &t } - code, err := s.st.CreateInvite(createdBy, maxUses, expiresAt) + code, err := s.st.CreateInvite(ctx, createdBy, maxUses, expiresAt) if err != nil { return nil, fmt.Errorf("%w: failed to create invite", ErrInternal) } - invite, err := s.st.GetInvite(code) + invite, err := s.st.GetInvite(ctx, code) if err != nil || invite == nil { return nil, fmt.Errorf("%w: failed to retrieve invite", ErrInternal) } @@ -61,8 +61,8 @@ func (s *InviteService) CreateInvite(ctx context.Context, createdBy int64, maxUs } // ListInvites returns all invites. -func (s *InviteService) ListInvites() ([]*db.Invite, error) { - invites, err := s.st.ListInvites() +func (s *InviteService) ListInvites(ctx context.Context) ([]*db.Invite, error) { + invites, err := s.st.ListInvites(ctx) if err != nil { return nil, fmt.Errorf("%w: failed to list invites", ErrInternal) } @@ -70,12 +70,12 @@ func (s *InviteService) ListInvites() ([]*db.Invite, error) { } // RevokeInvite revokes an invite by code. -func (s *InviteService) RevokeInvite(code string) error { - invite, err := s.st.GetInvite(code) +func (s *InviteService) RevokeInvite(ctx context.Context, code string) error { + invite, err := s.st.GetInvite(ctx, code) if err != nil || invite == nil { return fmt.Errorf("%w: invite not found", ErrNotFound) } - if err := s.st.RevokeInvite(code); err != nil { + if err := s.st.RevokeInvite(ctx, code); err != nil { return fmt.Errorf("%w: failed to revoke invite", ErrInternal) } return nil diff --git a/Server/service/message.go b/Server/service/message.go index 443beb11..8ee6272c 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -139,7 +139,7 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( return nil, fmt.Errorf("%w: channel_id must be a positive integer", ErrBadRequest) } - ch, err := s.st.GetChannel(p.ChannelID) + ch, err := s.st.GetChannel(ctx, p.ChannelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } @@ -147,12 +147,12 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( isDM := ch.Type == "dm" // Permission check. - if err := s.checkSendPermission(p.UserID, p.ChannelID, ch.Type); err != nil { + if err := s.checkSendPermission(ctx, p.UserID, p.ChannelID, ch.Type); err != nil { return nil, err } // Slow mode (non-DM only). - if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(p.UserID, p.ChannelID, permissions.ManageMessages) { + if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.ManageMessages) { slowKey := fmt.Sprintf("slow:%d:%d", p.UserID, p.ChannelID) if s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) { return nil, fmt.Errorf("%w: channel has %ds slow mode", ErrSlowMode, ch.SlowMode) @@ -167,13 +167,13 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( // Attachment permission (non-DM). if !isDM && len(p.AttachmentIDs) > 0 { - if !s.perms.HasChannelPerm(p.UserID, p.ChannelID, permissions.AttachFiles) { + if !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.AttachFiles) { return nil, fmt.Errorf("%w: missing ATTACH_FILES permission", ErrForbidden) } } // Persist message. - msgID, err := s.st.CreateMessage(p.ChannelID, p.UserID, content, p.ReplyTo) + msgID, err := s.st.CreateMessage(ctx, p.ChannelID, p.UserID, content, p.ReplyTo) if err != nil { slog.Error("MessageService.SendMessage CreateMessage", "err", err) return nil, fmt.Errorf("%w: failed to save message", ErrInternal) @@ -185,11 +185,12 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( // the statement — no check-then-link race and no N+1 pre-verification. var attachments []db.AttachmentInfo if len(p.AttachmentIDs) > 0 { - linked, linkErr := s.st.LinkAttachmentsToMessage(msgID, p.UserID, p.AttachmentIDs) + linked, linkErr := s.st.LinkAttachmentsToMessage(ctx, msgID, p.UserID, p.AttachmentIDs) if linkErr != nil { slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID) - // Cleanup: soft-delete the message. - if delErr := s.st.DeleteMessage(msgID, p.UserID, true); delErr != nil { + // Cleanup: soft-delete the message. The compensating delete must run + // even when the link failed because the request ctx was canceled. + if delErr := s.st.DeleteMessage(context.WithoutCancel(ctx), msgID, p.UserID, true); delErr != nil { slog.Error("MessageService.SendMessage DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID) } return nil, fmt.Errorf("%w: failed to send message with attachments", ErrInternal) @@ -199,7 +200,7 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( "msg_id", msgID, "user_id", p.UserID, "requested", len(p.AttachmentIDs), "linked", linked) } if linked > 0 { - attMap, attErr := s.st.GetAttachmentsByMessageIDs([]int64{msgID}) + attMap, attErr := s.st.GetAttachmentsByMessageIDs(ctx, []int64{msgID}) if attErr != nil { slog.Error("MessageService.SendMessage GetAttachments", "err", attErr) } else { @@ -208,8 +209,10 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } } - // Fetch message for timestamp. - msg, err := s.st.GetMessage(msgID) + // Fetch message for timestamp. Post-commit: the message exists whether or + // not the sender is still connected, so the refetch that feeds the fan-out + // must not die with the sender's ctx. + msg, err := s.st.GetMessage(context.WithoutCancel(ctx), msgID) if err != nil || msg == nil { slog.Error("MessageService.SendMessage GetMessage after create", "err", err) return nil, fmt.Errorf("%w: failed to retrieve message", ErrInternal) @@ -226,21 +229,21 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( // DM path: open DM for recipients. if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(p.ChannelID) + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, p.ChannelID) if pErr != nil { slog.Error("MessageService.SendMessage GetDMParticipantIDs", "err", pErr, "channel_id", p.ChannelID) return result, nil // Message saved, skip DM side effects. } result.ParticipantIDs = participantIDs - sender, _ := s.st.GetUserByID(p.UserID) + sender, _ := s.st.GetUserByID(ctx, p.UserID) result.SenderUser = sender for _, pid := range participantIDs { if pid == p.UserID { continue } - if openErr := s.st.OpenDM(pid, p.ChannelID); openErr != nil { + if openErr := s.st.OpenDM(ctx, pid, p.ChannelID); openErr != nil { slog.Error("MessageService.SendMessage OpenDM", "err", openErr, "recipient_id", pid, "channel_id", p.ChannelID) continue } @@ -253,7 +256,7 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } // EditMessage validates and persists a message edit. -func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*EditMessageResult, error) { +func (s *MessageService) EditMessage(ctx context.Context, userID, msgID int64, rawContent string) (*EditMessageResult, error) { // Rate limit. ratKey := fmt.Sprintf("chat_edit:%d", userID) if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { @@ -270,7 +273,7 @@ func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*E } // Fetch message. - msg, err := s.st.GetMessage(msgID) + msg, err := s.st.GetMessage(ctx, msgID) if err != nil || msg == nil { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } @@ -279,25 +282,26 @@ func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*E } // Channel type for DM-aware permissions. - ch, chErr := s.st.GetChannel(msg.ChannelID) + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) isDM := chErr == nil && ch != nil && ch.Type == "dm" if isDM { - ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID) + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) if dmErr != nil || !ok { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } - } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.SendMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.SendMessages) { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } // EditMessage checks ownership internally. - if err := s.st.EditMessage(msgID, userID, content); err != nil { + if err := s.st.EditMessage(ctx, msgID, userID, content); err != nil { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } - // Re-fetch for updated edited_at timestamp. - msg, err = s.st.GetMessage(msgID) + // Re-fetch for updated edited_at timestamp. Post-commit: must not die with + // the editor's ctx or the committed edit is never broadcast. + msg, err = s.st.GetMessage(context.WithoutCancel(ctx), msgID) if err != nil || msg == nil { slog.Error("MessageService.EditMessage GetMessage after edit", "err", err, "msg_id", msgID) return nil, fmt.Errorf("%w: edit saved but broadcast failed", ErrInternal) @@ -317,7 +321,7 @@ func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*E } if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(msg.ChannelID) + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) if pErr != nil { slog.Error("MessageService.EditMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) } else { @@ -330,7 +334,7 @@ func (s *MessageService) EditMessage(userID, msgID int64, rawContent string) (*E } // DeleteMessage validates and soft-deletes a message. -func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResult, error) { +func (s *MessageService) DeleteMessage(ctx context.Context, userID, msgID int64) (*DeleteMessageResult, error) { // Rate limit. ratKey := fmt.Sprintf("chat_delete:%d", userID) if s.limiter != nil && !s.limiter.Allow(ratKey, 10, time.Second) { @@ -341,35 +345,36 @@ func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResul return nil, fmt.Errorf("%w: message_id must be positive integer", ErrBadRequest) } - msg, err := s.st.GetMessage(msgID) + msg, err := s.st.GetMessage(ctx, msgID) if err != nil || msg == nil { return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) } - ch, chErr := s.st.GetChannel(msg.ChannelID) + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) isDM := chErr == nil && ch != nil && ch.Type == "dm" if isDM { - ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID) + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) if dmErr != nil || !ok { return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) } } else { isMsgOwner := msg.UserID == userID - canManage := s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ManageMessages) - canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.SendMessages)) + canManage := s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ManageMessages) + canDelete := canManage || (isMsgOwner && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.SendMessages)) if !canDelete { return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) } } - isMod := !isDM && s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ManageMessages) - if err := s.st.DeleteMessage(msgID, userID, isMod); err != nil { + isMod := !isDM && s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ManageMessages) + if err := s.st.DeleteMessage(ctx, msgID, userID, isMod); err != nil { return nil, fmt.Errorf("%w: cannot delete this message", ErrForbidden) } slog.Debug("message deleted", "user_id", userID, "msg_id", msgID, "channel_id", msg.ChannelID, "is_mod", isMod) - db.WriteAudit(s.st, userID, "message_delete", "message", msgID, + // Audit rows must survive a request canceled after the delete committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "message_delete", "message", msgID, fmt.Sprintf("channel %d, mod_action=%v", msg.ChannelID, isMod)) result := &DeleteMessageResult{ @@ -380,7 +385,7 @@ func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResul } if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(msg.ChannelID) + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) if pErr != nil { slog.Error("MessageService.DeleteMessage GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) } else { @@ -392,16 +397,16 @@ func (s *MessageService) DeleteMessage(userID, msgID int64) (*DeleteMessageResul } // AddReaction adds a reaction to a message. -func (s *MessageService) AddReaction(userID, msgID int64, emoji string) (*ReactionResult, error) { - return s.handleReaction(userID, msgID, emoji, true) +func (s *MessageService) AddReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) { + return s.handleReaction(ctx, userID, msgID, emoji, true) } // RemoveReaction removes a reaction from a message. -func (s *MessageService) RemoveReaction(userID, msgID int64, emoji string) (*ReactionResult, error) { - return s.handleReaction(userID, msgID, emoji, false) +func (s *MessageService) RemoveReaction(ctx context.Context, userID, msgID int64, emoji string) (*ReactionResult, error) { + return s.handleReaction(ctx, userID, msgID, emoji, false) } -func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add bool) (*ReactionResult, error) { +func (s *MessageService) handleReaction(ctx context.Context, userID, msgID int64, emoji string, add bool) (*ReactionResult, error) { // Rate limit. ratKey := fmt.Sprintf("reaction:%d", userID) if s.limiter != nil && !s.limiter.Allow(ratKey, 5, time.Second) { @@ -425,7 +430,7 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b return nil, fmt.Errorf("%w: emoji contains unsafe content", ErrBadRequest) } - msg, err := s.st.GetMessage(msgID) + msg, err := s.st.GetMessage(ctx, msgID) if err != nil || msg == nil { return nil, fmt.Errorf("%w: message not found", ErrBadRequest) } @@ -433,15 +438,15 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b return nil, fmt.Errorf("%w: cannot react to deleted message", ErrBadRequest) } - ch, chErr := s.st.GetChannel(msg.ChannelID) + ch, chErr := s.st.GetChannel(ctx, msg.ChannelID) isDM := chErr == nil && ch != nil && ch.Type == "dm" if isDM { - ok, dmErr := s.st.IsDMParticipant(userID, msg.ChannelID) + ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID) if dmErr != nil || !ok { return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest) } - } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) { + } else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) { // Require READ_MESSAGES in addition to ADD_REACTIONS so a user cannot // react in a channel they cannot read. Mirrors checkSendPermission, // which requires ReadMessages|SendMessages for non-DM sends. @@ -450,13 +455,13 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b action := "add" if add { - if err := s.st.AddReaction(msgID, userID, emoji); err != nil { + if err := s.st.AddReaction(ctx, msgID, userID, emoji); err != nil { slog.Warn("MessageService.AddReaction", "err", err, "msg_id", msgID, "user_id", userID) return nil, fmt.Errorf("%w: reaction already exists", ErrConflict) } } else { action = "remove" - if err := s.st.RemoveReaction(msgID, userID, emoji); err != nil { + if err := s.st.RemoveReaction(ctx, msgID, userID, emoji); err != nil { slog.Warn("MessageService.RemoveReaction", "err", err, "msg_id", msgID, "user_id", userID) return nil, fmt.Errorf("%w: reaction not found", ErrBadRequest) } @@ -472,7 +477,7 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b } if isDM { - participantIDs, pErr := s.st.GetDMParticipantIDs(msg.ChannelID) + participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID) if pErr != nil { slog.Error("MessageService.handleReaction GetDMParticipantIDs", "err", pErr, "channel_id", msg.ChannelID) } else { @@ -484,23 +489,23 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b } // GetMessages retrieves paginated messages for a channel with permission checks. -func (s *MessageService) GetMessages(userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) { +func (s *MessageService) GetMessages(ctx context.Context, userID, channelID, before int64, limit int) ([]db.MessageAPIResponse, bool, error) { if channelID <= 0 { return nil, false, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } - ch, err := s.st.GetChannel(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return nil, false, fmt.Errorf("%w: channel not found", ErrNotFound) } // Permission check. if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return nil, false, fmt.Errorf("%w: access denied", ErrNotFound) } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return nil, false, fmt.Errorf("%w: access denied", ErrForbidden) } @@ -512,7 +517,7 @@ func (s *MessageService) GetMessages(userID, channelID, before int64, limit int) } // Fetch one extra to detect has_more. - msgs, err := s.st.GetMessagesForAPI(channelID, before, limit+1, userID) + msgs, err := s.st.GetMessagesForAPI(ctx, channelID, before, limit+1, userID) if err != nil { slog.Error("MessageService.GetMessages", "err", err, "channel_id", channelID) return nil, false, fmt.Errorf("%w: failed to fetch messages", ErrInternal) @@ -527,7 +532,7 @@ func (s *MessageService) GetMessages(userID, channelID, before int64, limit int) } // SearchMessages performs full-text search across accessible channels. -func (s *MessageService) SearchMessages(userID int64, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { +func (s *MessageService) SearchMessages(ctx context.Context, userID int64, query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { if query == "" { return nil, fmt.Errorf("%w: query cannot be empty", ErrBadRequest) } @@ -540,19 +545,19 @@ func (s *MessageService) SearchMessages(userID int64, query string, channelID *i // Single-channel search. if channelID != nil && *channelID > 0 { - ch, err := s.st.GetChannel(*channelID) + ch, err := s.st.GetChannel(ctx, *channelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, *channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, *channelID) if err != nil || !ok { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } - } else if !s.perms.HasChannelPerm(userID, *channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, *channelID, permissions.ReadMessages) { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } - results, err := s.st.SearchMessages(query, channelID, limit) + results, err := s.st.SearchMessages(ctx, query, channelID, limit) if err != nil { return nil, fmt.Errorf("%w: search failed", ErrInternal) } @@ -560,7 +565,7 @@ func (s *MessageService) SearchMessages(userID int64, query string, channelID *i } // Global search: build accessible channel list. - accessibleIDs, err := s.GetAccessibleChannelIDs(userID) + accessibleIDs, err := s.GetAccessibleChannelIDs(ctx, userID) if err != nil { return nil, err } @@ -568,7 +573,7 @@ func (s *MessageService) SearchMessages(userID int64, query string, channelID *i return nil, nil } - results, err := s.st.SearchMessagesInChannels(query, accessibleIDs, limit) + results, err := s.st.SearchMessagesInChannels(ctx, query, accessibleIDs, limit) if err != nil { return nil, fmt.Errorf("%w: search failed", ErrInternal) } @@ -576,23 +581,23 @@ func (s *MessageService) SearchMessages(userID int64, query string, channelID *i } // GetPinnedMessages retrieves pinned messages for a channel. -func (s *MessageService) GetPinnedMessages(userID, channelID int64) ([]db.MessageAPIResponse, error) { +func (s *MessageService) GetPinnedMessages(ctx context.Context, userID, channelID int64) ([]db.MessageAPIResponse, error) { if channelID <= 0 { return nil, fmt.Errorf("%w: channel_id must be positive", ErrBadRequest) } - ch, err := s.st.GetChannel(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return nil, fmt.Errorf("%w: channel not found", ErrNotFound) } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return nil, fmt.Errorf("%w: access denied", ErrNotFound) } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { return nil, fmt.Errorf("%w: access denied", ErrForbidden) } - msgs, err := s.st.GetPinnedMessages(channelID, userID) + msgs, err := s.st.GetPinnedMessages(ctx, channelID, userID) if err != nil { return nil, fmt.Errorf("%w: failed to fetch pinned messages", ErrInternal) } @@ -600,38 +605,38 @@ func (s *MessageService) GetPinnedMessages(userID, channelID int64) ([]db.Messag } // SetMessagePinned pins or unpins a message. -func (s *MessageService) SetMessagePinned(userID, channelID, msgID int64, pinned bool) error { +func (s *MessageService) SetMessagePinned(ctx context.Context, userID, channelID, msgID int64, pinned bool) error { if channelID <= 0 || msgID <= 0 { return fmt.Errorf("%w: invalid IDs", ErrBadRequest) } - ch, err := s.st.GetChannel(channelID) + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return fmt.Errorf("%w: channel not found", ErrNotFound) } if ch.Type == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil || !ok { return fmt.Errorf("%w: access denied", ErrNotFound) } - } else if !s.perms.HasChannelPerm(userID, channelID, permissions.ManageMessages) { + } else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ManageMessages) { return fmt.Errorf("%w: missing MANAGE_MESSAGES permission", ErrForbidden) } // Verify message belongs to this channel. - msg, err := s.st.GetMessage(msgID) + msg, err := s.st.GetMessage(ctx, msgID) if err != nil || msg == nil || msg.ChannelID != channelID { return fmt.Errorf("%w: message not found in this channel", ErrNotFound) } - return s.st.SetMessagePinned(msgID, pinned) + return s.st.SetMessagePinned(ctx, msgID, pinned) } // GetAccessibleChannelIDs returns all channel IDs the user can read. -func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) { - channels, err := s.st.ListChannels() +func (s *MessageService) GetAccessibleChannelIDs(ctx context.Context, userID int64) ([]int64, error) { + channels, err := s.st.ListChannels(ctx) if err != nil { return nil, fmt.Errorf("%w: failed to list channels", ErrInternal) } - role, err := s.perms.GetRoleForUser(userID) + role, err := s.perms.GetRoleForUser(ctx, userID) if err != nil || role == nil { return nil, fmt.Errorf("%w: failed to get role", ErrInternal) } @@ -639,7 +644,7 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { var overrideErr error - overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(role.ID) + overrides, overrideErr = s.st.GetAllChannelPermissionsForRole(ctx, role.ID) if overrideErr != nil { return nil, fmt.Errorf("%w: failed to fetch channel overrides", ErrInternal) } @@ -656,7 +661,7 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) } // Also include DM channels the user participates in. - dmChannels, err := s.st.GetUserDMChannels(userID) + dmChannels, err := s.st.GetUserDMChannels(ctx, userID) if err == nil { for _, dmc := range dmChannels { ids = append(ids, dmc.ChannelID) @@ -671,31 +676,31 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) // for regular channels; participant membership AND block status for DMs. // Exists so gates outside the send flow (the plugin broadcast path) share // exactly this policy instead of hand-rolling a weaker copy. -func (s *MessageService) CanPost(userID, channelID int64) error { - ch, err := s.st.GetChannel(channelID) +func (s *MessageService) CanPost(ctx context.Context, userID, channelID int64) error { + ch, err := s.st.GetChannel(ctx, channelID) if err != nil || ch == nil { return fmt.Errorf("%w: channel not found", ErrNotFound) } - return s.checkSendPermission(userID, channelID, ch.Type) + return s.checkSendPermission(ctx, userID, channelID, ch.Type) } // checkSendPermission validates send permission for a channel of the given // type. Announcement channels are readable by anyone with READ_MESSAGES but // only postable by users with MANAGE_MESSAGES (posting is restricted to // moderators/admins); all other non-DM channels require SEND_MESSAGES. -func (s *MessageService) checkSendPermission(userID, channelID int64, chanType string) error { +func (s *MessageService) checkSendPermission(ctx context.Context, userID, channelID int64, chanType string) error { isDM := chanType == "dm" if isDM { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil { return fmt.Errorf("%w: failed to check DM participation", ErrInternal) } if !ok { return fmt.Errorf("%w: not a participant in this DM", ErrForbidden) } - recipient, err := s.st.GetDMRecipient(channelID, userID) + recipient, err := s.st.GetDMRecipient(ctx, channelID, userID) if err == nil && recipient != nil { - blocked, blkErr := s.st.IsEitherBlocked(userID, recipient.ID) + blocked, blkErr := s.st.IsEitherBlocked(ctx, userID, recipient.ID) if blkErr != nil { return fmt.Errorf("%w: failed to check block status", ErrInternal) } @@ -705,12 +710,12 @@ func (s *MessageService) checkSendPermission(userID, channelID int64, chanType s } return nil } - if !s.perms.HasChannelPerm(userID, channelID, permissions.ReadMessages|permissions.SendMessages) { + if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages|permissions.SendMessages) { return fmt.Errorf("%w: missing SEND_MESSAGES permission", ErrForbidden) } // Announcement channels: posting is restricted to users who can manage // messages, even though everyone with READ_MESSAGES can view them. - if chanType == "announcement" && !s.perms.HasChannelPerm(userID, channelID, permissions.ManageMessages) { + if chanType == "announcement" && !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ManageMessages) { return fmt.Errorf("%w: announcement channels require MANAGE_MESSAGES to post", ErrForbidden) } return nil diff --git a/Server/service/message_test.go b/Server/service/message_test.go index 594d0faf..ca61b003 100644 --- a/Server/service/message_test.go +++ b/Server/service/message_test.go @@ -76,17 +76,17 @@ func TestCanPost_DMBlockEnforced(t *testing.T) { checker := permissions.NewChecker(database) svc := NewMessageService(database, NewPermissionService(database, checker), nil) - if err := svc.CanPost(1, 50); err != nil { + if err := svc.CanPost(context.Background(), 1, 50); err != nil { t.Fatalf("unblocked DM participant should be allowed: %v", err) } seedBlock(t, database, 2, 1) // bob blocks alice - if err := svc.CanPost(1, 50); !errors.Is(err, ErrBlocked) { + if err := svc.CanPost(context.Background(), 1, 50); !errors.Is(err, ErrBlocked) { t.Fatalf("blocked user must be refused: got %v", err) } - if err := svc.CanPost(3, 50); !errors.Is(err, ErrForbidden) { + if err := svc.CanPost(context.Background(), 3, 50); !errors.Is(err, ErrForbidden) { t.Fatalf("non-participant must be refused: got %v", err) } - if err := svc.CanPost(1, 999); !errors.Is(err, ErrNotFound) { + if err := svc.CanPost(context.Background(), 1, 999); !errors.Is(err, ErrNotFound) { t.Fatalf("missing channel must be NotFound: got %v", err) } } @@ -105,7 +105,7 @@ func TestCanPost_ChannelPermissionRequired(t *testing.T) { checker := permissions.NewChecker(database) svc := NewMessageService(database, NewPermissionService(database, checker), nil) - if err := svc.CanPost(1, 10); !errors.Is(err, ErrForbidden) { + if err := svc.CanPost(context.Background(), 1, 10); !errors.Is(err, ErrForbidden) { t.Fatalf("missing SEND_MESSAGES must refuse: got %v", err) } } @@ -132,11 +132,11 @@ func TestCanPost_AnnouncementRequiresManageMessages(t *testing.T) { svc := NewMessageService(database, NewPermissionService(database, checker), nil) // Member has READ|SEND but not MANAGE_MESSAGES → refused in an announcement channel. - if err := svc.CanPost(1, 20); !errors.Is(err, ErrForbidden) { + if err := svc.CanPost(context.Background(), 1, 20); !errors.Is(err, ErrForbidden) { t.Fatalf("member without MANAGE_MESSAGES must be refused in announcement channel: got %v", err) } // Moderator with MANAGE_MESSAGES → allowed. - if err := svc.CanPost(2, 20); err != nil { + if err := svc.CanPost(context.Background(), 2, 20); err != nil { t.Fatalf("moderator with MANAGE_MESSAGES must post in announcement channel: got %v", err) } } @@ -161,10 +161,10 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { checker := permissions.NewChecker(database) svc := NewMessageService(database, NewPermissionService(database, checker), nil) - if err := database.CreateAttachment("att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil { + if err := database.CreateAttachment(context.Background(), "att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil { t.Fatal(err) } - if err := database.CreateAttachment("att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil { + if err := database.CreateAttachment(context.Background(), "att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil { t.Fatal(err) } @@ -180,11 +180,11 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { t.Fatal("message should persist even when some attachments are skipped") } - own, _ := database.GetAttachmentByID("att-own") + own, _ := database.GetAttachmentByID(context.Background(), "att-own") if own.MessageID == nil || *own.MessageID != result.MessageID { t.Error("sender's own attachment should be linked to the new message") } - foreign, _ := database.GetAttachmentByID("att-foreign") + foreign, _ := database.GetAttachmentByID(context.Background(), "att-foreign") if foreign.MessageID != nil { t.Error("another user's attachment must never be linked (IDOR guard)") } @@ -201,7 +201,7 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { if retry.MessageID <= 0 { t.Fatal("retry should persist a message") } - own2, _ := database.GetAttachmentByID("att-own") + own2, _ := database.GetAttachmentByID(context.Background(), "att-own") if own2.MessageID == nil || *own2.MessageID != result.MessageID { t.Error("already-linked attachment must stay linked to the original message") } @@ -325,7 +325,7 @@ func TestEditMessage_OwnerCanEdit(t *testing.T) { t.Fatalf("send: %v", err) } - editResult, err := svc.EditMessage(1, result.MessageID, "edited content") + editResult, err := svc.EditMessage(context.Background(), 1, result.MessageID, "edited content") if err != nil { t.Fatalf("edit: %v", err) } @@ -355,7 +355,7 @@ func TestEditMessage_NonOwnerFails(t *testing.T) { } // User 2 tries to edit it. - _, err = svc.EditMessage(2, result.MessageID, "hacked") + _, err = svc.EditMessage(context.Background(), 2, result.MessageID, "hacked") if err == nil { t.Fatal("expected error when non-owner edits message") } @@ -377,7 +377,7 @@ func TestEditMessage_EmptyContentFails(t *testing.T) { t.Fatalf("send: %v", err) } - _, err = svc.EditMessage(1, result.MessageID, "") + _, err = svc.EditMessage(context.Background(), 1, result.MessageID, "") if err == nil { t.Fatal("expected error for empty edit content") } @@ -399,7 +399,7 @@ func TestDeleteMessage_OwnerCanDelete(t *testing.T) { t.Fatalf("send: %v", err) } - delResult, err := svc.DeleteMessage(1, result.MessageID) + delResult, err := svc.DeleteMessage(context.Background(), 1, result.MessageID) if err != nil { t.Fatalf("delete: %v", err) } @@ -428,7 +428,7 @@ func TestDeleteMessage_NonOwnerWithoutModFails(t *testing.T) { } // User 2 (no ManageMessages) tries to delete user 1's message. - _, err = svc.DeleteMessage(2, result.MessageID) + _, err = svc.DeleteMessage(context.Background(), 2, result.MessageID) if err == nil { t.Fatal("expected error when non-owner without mod perms deletes message") } @@ -475,7 +475,7 @@ func TestDeleteMessage_ModCanDeleteOthersMessage(t *testing.T) { } // Mod (user 2) deletes it. - delResult, err := svc.DeleteMessage(2, result.MessageID) + delResult, err := svc.DeleteMessage(context.Background(), 2, result.MessageID) if err != nil { t.Fatalf("mod delete: %v", err) } @@ -487,7 +487,7 @@ func TestDeleteMessage_ModCanDeleteOthersMessage(t *testing.T) { func TestDeleteMessage_InvalidMessageID(t *testing.T) { svc, _ := newTestMessageService(t) - _, err := svc.DeleteMessage(1, 0) + _, err := svc.DeleteMessage(context.Background(), 1, 0) if err == nil { t.Fatal("expected error for zero message ID") } diff --git a/Server/service/moderation.go b/Server/service/moderation.go index e6cce0c9..03198e93 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -26,12 +26,12 @@ func NewModerationService(st Store, perms *PermissionService) *ModerationService // Administrator bypass). It deliberately takes no target: it runs before any // target lookup so an actor without ban authority always sees Forbidden and // never NotFound — the ban path cannot be used to enumerate user ids. -func (s *ModerationService) requireBanPermission(actorID int64) error { +func (s *ModerationService) requireBanPermission(ctx context.Context, actorID int64) error { if s.perms == nil { // No permission service wired — fail closed rather than allow unchecked bans. return fmt.Errorf("%w: permission service unavailable", ErrForbidden) } - actorRole, err := s.perms.GetRoleForUser(actorID) + actorRole, err := s.perms.GetRoleForUser(ctx, actorID) if err != nil || actorRole == nil { return fmt.Errorf("%w: failed to load actor role", ErrForbidden) } @@ -46,12 +46,12 @@ func (s *ModerationService) requireBanPermission(actorID int64) error { // (e.g. the owner) — mirroring the position-based hierarchy used elsewhere. // Runs after requireBanPermission and the existence check, so only callers // that already hold ban authority reach it. -func (s *ModerationService) requireOutranks(actorID, targetID int64) error { - actorRole, err := s.perms.GetRoleForUser(actorID) +func (s *ModerationService) requireOutranks(ctx context.Context, actorID, targetID int64) error { + actorRole, err := s.perms.GetRoleForUser(ctx, actorID) if err != nil || actorRole == nil { return fmt.Errorf("%w: failed to load actor role", ErrForbidden) } - targetRole, err := s.perms.GetRoleForUser(targetID) + targetRole, err := s.perms.GetRoleForUser(ctx, targetID) if err != nil || targetRole == nil { return fmt.Errorf("%w: failed to load target role", ErrForbidden) } @@ -84,50 +84,52 @@ func (s *ModerationService) BanUser(ctx context.Context, actorID, targetID int64 // Authorization before existence: an actor without ban authority learns // nothing about which user ids exist. - if err := s.requireBanPermission(actorID); err != nil { + if err := s.requireBanPermission(ctx, actorID); err != nil { return err } - target, err := s.st.GetUserByID(targetID) + target, err := s.st.GetUserByID(ctx, targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } - if err := s.requireOutranks(actorID, targetID); err != nil { + if err := s.requireOutranks(ctx, actorID, targetID); err != nil { return err } - if err := s.st.BanUser(targetID, reason, expires); err != nil { + if err := s.st.BanUser(ctx, targetID, reason, expires); err != nil { return fmt.Errorf("%w: failed to ban user", ErrInternal) } - db.WriteAudit(s.st, actorID, "user_ban", "user", targetID, reason) + // Audit rows must survive a request canceled after the ban committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "user_ban", "user", targetID, reason) slog.Info("user banned", "actor_id", actorID, "target_id", targetID, "reason", reason) return nil } // UnbanUser removes a ban on a target user. -func (s *ModerationService) UnbanUser(_ context.Context, actorID, targetID int64) error { +func (s *ModerationService) UnbanUser(ctx context.Context, actorID, targetID int64) error { if targetID <= 0 { return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) } // Authorization before existence — see BanUser. - if err := s.requireBanPermission(actorID); err != nil { + if err := s.requireBanPermission(ctx, actorID); err != nil { return err } - target, err := s.st.GetUserByID(targetID) + target, err := s.st.GetUserByID(ctx, targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } - if err := s.requireOutranks(actorID, targetID); err != nil { + if err := s.requireOutranks(ctx, actorID, targetID); err != nil { return err } - if err := s.st.UnbanUser(targetID); err != nil { + if err := s.st.UnbanUser(ctx, targetID); err != nil { return fmt.Errorf("%w: failed to unban user", ErrInternal) } - db.WriteAudit(s.st, actorID, "user_unban", "user", targetID, "") + // Audit rows must survive a request canceled after the unban committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "user_unban", "user", targetID, "") slog.Info("user unbanned", "actor_id", actorID, "target_id", targetID) return nil diff --git a/Server/service/moderation_test.go b/Server/service/moderation_test.go index 4b008e9c..2f0cf36b 100644 --- a/Server/service/moderation_test.go +++ b/Server/service/moderation_test.go @@ -52,7 +52,7 @@ func TestBanUser_HierarchyEnforced(t *testing.T) { if err := svc.BanUser(context.Background(), 2, 1, "coup", nil); !errors.Is(err, ErrForbidden) { t.Fatalf("ban owner: want ErrForbidden, got %v", err) } - owner, _ := database.GetUserByID(1) + owner, _ := database.GetUserByID(context.Background(), 1) if owner.Banned { t.Fatal("owner must not be banned") } @@ -64,7 +64,7 @@ func TestBanUser_AuthorizedSucceeds(t *testing.T) { if err := svc.BanUser(context.Background(), 2, 3, "spam", nil); err != nil { t.Fatalf("authorized ban: %v", err) } - target, _ := database.GetUserByID(3) + target, _ := database.GetUserByID(context.Background(), 3) if !target.Banned { t.Fatal("target should be banned") } @@ -101,7 +101,7 @@ func TestUnbanUser_AuthorizationMatrix(t *testing.T) { if err := svc.UnbanUser(context.Background(), 2, 3); err != nil { t.Fatalf("authorized unban: %v", err) } - target, _ := database.GetUserByID(3) + target, _ := database.GetUserByID(context.Background(), 3) if target.Banned { t.Fatal("target should be unbanned") } diff --git a/Server/service/permission.go b/Server/service/permission.go index 498e1a9d..97b05cd8 100644 --- a/Server/service/permission.go +++ b/Server/service/permission.go @@ -49,17 +49,18 @@ func NewPermissionService(st Store, checker *permissions.Checker) *PermissionSer // HasChannelPerm reports whether the user has the required permission bits // on the given channel. Uses cached role/override data when available. -func (s *PermissionService) HasChannelPerm(userID, channelID, perm int64) bool { +// Cancellation of ctx reaches the underlying store reads. +func (s *PermissionService) HasChannelPerm(ctx context.Context, userID, channelID, perm int64) bool { // Phase B Step 8 — span the perm check so traces show how many permission // lookups a single REST/WS request triggers. The cache hit path is fast, // but knowing how often it misses is the whole point of having metrics. - _, span := telemetry.GlobalTracer("service/permission").Start(context.Background(), + ctx, span := telemetry.GlobalTracer("service/permission").Start(ctx, "PermissionService.HasChannelPerm", telemetry.Int64("user_id", userID), telemetry.Int64("channel_id", channelID), ) defer span.End() - cp := s.getOrPopulate(userID) + cp := s.getOrPopulate(ctx, userID) if cp == nil { return false } @@ -69,9 +70,9 @@ func (s *PermissionService) HasChannelPerm(userID, channelID, perm int64) bool { // RequireChannelAccess checks whether the user can access the channel with // the given permission. For DM channels it verifies participant membership. // For regular channels it uses cached role-based permission checks. -func (s *PermissionService) RequireChannelAccess(userID int64, channelType string, channelID, perm int64) error { +func (s *PermissionService) RequireChannelAccess(ctx context.Context, userID int64, channelType string, channelID, perm int64) error { if channelType == "dm" { - ok, err := s.st.IsDMParticipant(userID, channelID) + ok, err := s.st.IsDMParticipant(ctx, userID, channelID) if err != nil { return err } @@ -80,20 +81,20 @@ func (s *PermissionService) RequireChannelAccess(userID int64, channelType strin } return nil } - if !s.HasChannelPerm(userID, channelID, perm) { + if !s.HasChannelPerm(ctx, userID, channelID, perm) { return permissions.ErrPermissionDenied } return nil } // GetRoleForUser returns the user's role, using the cache when available. -func (s *PermissionService) GetRoleForUser(userID int64) (*db.Role, error) { - cp := s.getOrPopulate(userID) +func (s *PermissionService) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) { + cp := s.getOrPopulate(ctx, userID) if cp == nil { // Cache miss, fall back to direct DB query. - return s.st.GetRoleForUser(userID) + return s.st.GetRoleForUser(ctx, userID) } - return s.st.GetRoleByID(cp.roleID) + return s.st.GetRoleByID(ctx, cp.roleID) } // InvalidateUser removes cached permissions for a specific user. @@ -131,7 +132,7 @@ func (s *PermissionService) Checker() *permissions.Checker { // getOrPopulate returns cached perms for the user, populating the cache // on miss or staleness. Returns nil if the user's role can't be loaded. -func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { +func (s *PermissionService) getOrPopulate(ctx context.Context, userID int64) *cachedPerms { s.mu.RLock() cp, ok := s.cache[userID] if ok && time.Since(cp.populatedAt) < permCacheTTL { @@ -142,7 +143,7 @@ func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { s.mu.RUnlock() // Populate. - role, err := s.st.GetRoleForUser(userID) + role, err := s.st.GetRoleForUser(ctx, userID) if err != nil || role == nil { return nil } @@ -150,7 +151,7 @@ func (s *PermissionService) getOrPopulate(userID int64) *cachedPerms { // ChannelService.ListVisibleChannels and ws.buildReady). var overrides map[int64]permissions.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - raw, oErr := s.st.GetAllChannelPermissionsForRole(role.ID) + raw, oErr := s.st.GetAllChannelPermissionsForRole(ctx, role.ID) if oErr != nil { // Fail closed: an empty map would silently drop every deny bit, // and caching it would keep doing so for permCacheTTL. diff --git a/Server/service/permission_test.go b/Server/service/permission_test.go index f584a3b8..1c56ae7a 100644 --- a/Server/service/permission_test.go +++ b/Server/service/permission_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "testing" "time" @@ -17,7 +18,7 @@ type errOverrideStore struct { *db.DB } -func (errOverrideStore) GetAllChannelPermissionsForRole(int64) (map[int64]db.ChannelOverride, error) { +func (errOverrideStore) GetAllChannelPermissionsForRole(context.Context, int64) (map[int64]db.ChannelOverride, error) { return nil, errors.New("boom") } @@ -39,7 +40,7 @@ func TestHasChannelPerm_OverrideFetchErrorDenies(t *testing.T) { svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database)) - if svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) { t.Fatal("override fetch failure must deny, not fall back to the base role bits") } } @@ -65,10 +66,10 @@ func TestHasChannelPerm_Allowed(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Member has SendMessages | ReadMessages; no overrides exist, so base role perms apply. - if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected user to have SendMessages permission") } - if !svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) { t.Fatal("expected user to have ReadMessages permission") } } @@ -78,7 +79,7 @@ func TestHasChannelPerm_Denied(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // ManageMessages is NOT in the member role. - if svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) { t.Fatal("expected user to NOT have ManageMessages permission") } } @@ -91,11 +92,11 @@ func TestHasChannelPerm_OverrideDeny(t *testing.T) { // Invalidate so next check re-populates cache. svc.InvalidateAll() - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected SendMessages to be denied via channel override") } // ReadMessages should still be allowed. - if !svc.HasChannelPerm(1, 10, permissions.ReadMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ReadMessages) { t.Fatal("expected ReadMessages to remain allowed") } } @@ -107,7 +108,7 @@ func TestHasChannelPerm_OverrideAllow(t *testing.T) { seedChannelOverride(t, database, permissions.MemberRoleID, 10, permissions.ManageMessages, 0) svc.InvalidateAll() - if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) { t.Fatal("expected ManageMessages to be allowed via channel override") } } @@ -128,10 +129,10 @@ func TestHasChannelPerm_AdminBypass(t *testing.T) { // Deny everything via override; admin should still bypass. seedChannelOverride(t, database, permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages) - if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("admin should bypass all permission checks") } - if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) { t.Fatal("admin should bypass all permission checks") } } @@ -153,7 +154,7 @@ func TestHasChannelPerm_AdminSkipsOverrideFetch(t *testing.T) { svc := NewPermissionService(errOverrideStore{DB: database}, permissions.NewChecker(database)) - if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.ManageMessages) { t.Fatal("admin must not be denied by an override-fetch outage; the fetch is skipped for admins") } } @@ -163,19 +164,19 @@ func TestInvalidateUser_ClearsCacheForUser(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache. - svc.HasChannelPerm(1, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) // Now add a deny override. seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) // Without invalidation, cache still says allowed. - if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected cached value to still allow SendMessages") } // After invalidation, should pick up the override. svc.InvalidateUser(1) - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected SendMessages to be denied after cache invalidation") } } @@ -187,27 +188,27 @@ func TestInvalidateAll_ClearsEntireCache(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache for both users. - svc.HasChannelPerm(1, 10, permissions.SendMessages) - svc.HasChannelPerm(2, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages) // Add deny override. seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) // Both still cached as allowed. - if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected cached allow for user 1") } - if !svc.HasChannelPerm(2, 10, permissions.SendMessages) { + if !svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages) { t.Fatal("expected cached allow for user 2") } svc.InvalidateAll() // Both should now see the deny. - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected deny for user 1 after InvalidateAll") } - if svc.HasChannelPerm(2, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 2, 10, permissions.SendMessages) { t.Fatal("expected deny for user 2 after InvalidateAll") } } @@ -221,7 +222,7 @@ func TestPermCacheTTLExpiry(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache. - svc.HasChannelPerm(1, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) // Add deny override. seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) @@ -234,7 +235,7 @@ func TestPermCacheTTLExpiry(t *testing.T) { svc.mu.Unlock() // The next call should re-populate and pick up the deny. - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("expected cache TTL expiry to cause re-population with deny override") } } @@ -244,7 +245,7 @@ func TestHasChannelPerm_UnknownUserReturnsFalse(t *testing.T) { seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // User 999 has no role assigned. - if svc.HasChannelPerm(999, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 999, 10, permissions.SendMessages) { t.Fatal("expected false for unknown user") } } @@ -257,8 +258,8 @@ type raceHookStore struct { onGetRole func() } -func (s *raceHookStore) GetRoleForUser(userID int64) (*db.Role, error) { - r, err := s.DB.GetRoleForUser(userID) +func (s *raceHookStore) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) { + r, err := s.DB.GetRoleForUser(ctx, userID) if s.onGetRole != nil { s.onGetRole() } @@ -302,7 +303,7 @@ func TestGetOrPopulate_InvalidationDuringPopulateNotLost(t *testing.T) { fired = true // Admin demotes the role (removes SendMessages) and invalidates, // racing this populate between its role read and its cache store. - if _, err := database.Exec(`UPDATE roles SET permissions = ? WHERE id = ?`, + if _, err := database.ExecContext(context.Background(), `UPDATE roles SET permissions = ? WHERE id = ?`, permissions.ReadMessages, permissions.MemberRoleID); err != nil { t.Errorf("demote role: %v", err) } @@ -311,10 +312,10 @@ func TestGetOrPopulate_InvalidationDuringPopulateNotLost(t *testing.T) { // This populate reads the pre-demotion perms; the racing invalidation // must stop that stale snapshot from being cached. - svc.HasChannelPerm(1, 10, permissions.SendMessages) + svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) // A fresh check must re-read the DB and see the revoked permission. - if svc.HasChannelPerm(1, 10, permissions.SendMessages) { + if svc.HasChannelPerm(context.Background(), 1, 10, permissions.SendMessages) { t.Fatal("revoked SendMessages served from a stale snapshot; a populate that races an invalidation must not be cached") } }) diff --git a/Server/service/seed_test.go b/Server/service/seed_test.go index 547064e6..163c2432 100644 --- a/Server/service/seed_test.go +++ b/Server/service/seed_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "strconv" "testing" @@ -34,7 +35,7 @@ func newTestDB(t *testing.T) *db.DB { // collides with one of the migration-seeded defaults). func seedRole(t *testing.T, database *db.DB, r *db.Role) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO roles (id, name, color, permissions, position, is_default) VALUES (?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET @@ -54,7 +55,7 @@ func seedRole(t *testing.T, database *db.DB, r *db.Role) { // seedUser call and only writes role_id. func seedUserRole(t *testing.T, database *db.DB, userID, roleID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO users (id, username, password, role_id) VALUES (?, ?, '', ?) ON CONFLICT(id) DO UPDATE SET role_id=excluded.role_id`, @@ -89,7 +90,7 @@ func seedUser(t *testing.T, database *db.DB, u *db.User) { if u.Banned { banned = 1 } - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO users (id, username, password, avatar, status, banned, ban_reason) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -113,7 +114,7 @@ func seedChannel(t *testing.T, database *db.DB, ch *db.Channel) { if ctype == "" { ctype = "text" } - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channels (id, name, type, category, topic, position) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -132,7 +133,7 @@ func seedChannel(t *testing.T, database *db.DB, ch *db.Channel) { // seedChannelOverride sets a per-channel permission override for a role. func seedChannelOverride(t *testing.T, database *db.DB, roleID, channelID, allow, deny int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?) ON CONFLICT(channel_id, role_id) DO UPDATE SET @@ -148,7 +149,7 @@ func seedChannelOverride(t *testing.T, database *db.DB, roleID, channelID, allow // seedDMParticipant adds a user as a participant of a DM channel. func seedDMParticipant(t *testing.T, database *db.DB, channelID, userID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT OR IGNORE INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, channelID, userID, ) @@ -160,7 +161,7 @@ func seedDMParticipant(t *testing.T, database *db.DB, channelID, userID int64) { // seedBlock records that blockerID has blocked blockedID. func seedBlock(t *testing.T, database *db.DB, blockerID, blockedID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)`, blockerID, blockedID, ) diff --git a/Server/service/user.go b/Server/service/user.go index 70590c9a..0c51c646 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -34,17 +34,18 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username span.End() }() - if err := s.st.UpdateUserProfile(userID, username, avatar); err != nil { + if err := s.st.UpdateUserProfile(ctx, userID, username, avatar); err != nil { if db.IsUniqueConstraintError(err) { return nil, fmt.Errorf("%w: username is already taken", ErrConflict) } return nil, fmt.Errorf("%w: failed to update profile", ErrInternal) } - user, err := s.st.GetUserByID(userID) + user, err := s.st.GetUserByID(ctx, userID) if err != nil { return nil, fmt.Errorf("%w: failed to fetch updated user", ErrInternal) } - db.WriteAudit(s.st, userID, "profile_update", "user", userID, + // Audit rows must survive a request canceled after the write committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "profile_update", "user", userID, fmt.Sprintf("username=%s", username)) slog.Info("profile updated", "user_id", userID, "username", username) return user, nil @@ -62,36 +63,39 @@ type ChangePasswordResult struct { } // ChangePassword updates the user's password and revokes other sessions. -func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) { - if err := s.st.UpdateUserPassword(userID, newPasswordHash); err != nil { +func (s *UserService) ChangePassword(ctx context.Context, userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) { + if err := s.st.UpdateUserPassword(ctx, userID, newPasswordHash); err != nil { return ChangePasswordResult{}, fmt.Errorf("%w: failed to update password", ErrInternal) } // The password is committed from here on: every path below reports - // success and writes the audit row. + // success and writes the audit row — even if the request ctx has been + // canceled, revocation and audit are the security tail of the change. + tailCtx := context.WithoutCancel(ctx) + var res ChangePasswordResult - revoked, err := s.st.DeleteOtherSessions(userID, keepSessionID) + revoked, err := s.st.DeleteOtherSessions(tailCtx, userID, keepSessionID) res.SessionsRevoked = revoked if err != nil { slog.Error("UserService.ChangePassword DeleteOtherSessions", "err", err, "user_id", userID) // One bounded compensating retry: revocation is the security tail of // the change and a single immediate retry covers transient write-lock // contention. ponytail: one retry, add backoff only if logs show it. - if revokedRetry, retryErr := s.st.DeleteOtherSessions(userID, keepSessionID); retryErr == nil { + if revokedRetry, retryErr := s.st.DeleteOtherSessions(tailCtx, userID, keepSessionID); retryErr == nil { res.SessionsRevoked += revokedRetry } else { res.RevokeFailed = true } } - db.WriteAudit(s.st, userID, "password_change", "user", userID, "password changed") + db.WriteAudit(tailCtx, s.st, userID, "password_change", "user", userID, "password changed") slog.Info("password changed", "user_id", userID, "sessions_revoked", res.SessionsRevoked, "revoke_failed", res.RevokeFailed) return res, nil } // ListSessions returns all active sessions for a user. -func (s *UserService) ListSessions(userID int64) ([]db.Session, error) { - sessions, err := s.st.ListUserSessions(userID) +func (s *UserService) ListSessions(ctx context.Context, userID int64) ([]db.Session, error) { + sessions, err := s.st.ListUserSessions(ctx, userID) if err != nil { return nil, fmt.Errorf("%w: failed to list sessions", ErrInternal) } @@ -99,14 +103,15 @@ func (s *UserService) ListSessions(userID int64) ([]db.Session, error) { } // RevokeSession deletes a specific session owned by the user. -func (s *UserService) RevokeSession(userID, sessionID int64) error { - if err := s.st.DeleteSessionByID(sessionID, userID); err != nil { +func (s *UserService) RevokeSession(ctx context.Context, userID, sessionID int64) error { + if err := s.st.DeleteSessionByID(ctx, sessionID, userID); err != nil { if errors.Is(err, db.ErrNotFound) { return fmt.Errorf("%w: session not found", ErrNotFound) } return fmt.Errorf("%w: failed to revoke session", ErrInternal) } - db.WriteAudit(s.st, userID, "session_revoke", "session", sessionID, "session revoked") + // Audit rows must survive a request canceled after the delete committed. + db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "session_revoke", "session", sessionID, "session revoked") slog.Info("session revoked", "user_id", userID, "session_id", sessionID) return nil } diff --git a/Server/service/user_test.go b/Server/service/user_test.go index 13864de6..4052b4b6 100644 --- a/Server/service/user_test.go +++ b/Server/service/user_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "slices" "testing" @@ -20,7 +21,7 @@ type pwStore struct { audits []string } -func (f *pwStore) DeleteOtherSessions(_, _ int64) (int64, error) { +func (f *pwStore) DeleteOtherSessions(_ context.Context, _, _ int64) (int64, error) { f.revokeCalls++ if f.revokeCalls <= f.failRevokes { return 0, errors.New("session table locked") @@ -28,7 +29,7 @@ func (f *pwStore) DeleteOtherSessions(_, _ int64) (int64, error) { return 2, nil } -func (f *pwStore) LogAudit(_ int64, action, _ string, _ int64, _ string) error { +func (f *pwStore) LogAudit(_ context.Context, _ int64, action, _ string, _ int64, _ string) error { f.audits = append(f.audits, action) return nil } @@ -43,14 +44,14 @@ func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) { fs := &pwStore{DB: database, failRevokes: 99} svc := NewUserService(fs) - res, err := svc.ChangePassword(7, "newhash", 1) + res, err := svc.ChangePassword(context.Background(), 7, "newhash", 1) if err != nil { t.Fatalf("committed password change must not return an error: %v", err) } if !res.RevokeFailed { t.Fatal("RevokeFailed should be set when revocation keeps failing") } - if u, _ := database.GetUserByID(7); u.PasswordHash != "newhash" { + if u, _ := database.GetUserByID(context.Background(), 7); u.PasswordHash != "newhash" { t.Fatal("password should be committed") } if !slices.Contains(fs.audits, "password_change") { @@ -66,7 +67,7 @@ func TestChangePassword_RetryRecoversRevocation(t *testing.T) { fs := &pwStore{DB: database, failRevokes: 1} svc := NewUserService(fs) - res, err := svc.ChangePassword(7, "newhash", 1) + res, err := svc.ChangePassword(context.Background(), 7, "newhash", 1) if err != nil { t.Fatalf("ChangePassword: %v", err) } diff --git a/Server/telemetry/telemetry.go b/Server/telemetry/telemetry.go index 6db5da3e..ea8d363b 100644 --- a/Server/telemetry/telemetry.go +++ b/Server/telemetry/telemetry.go @@ -58,6 +58,9 @@ func String(k, v string) Attr { return Attr{Key: k, Value: v} } // Int64 constructs an int64 attribute. func Int64(k string, v int64) Attr { return Attr{Key: k, Value: v} } +// Float64 constructs a float64 attribute. +func Float64(k string, v float64) Attr { return Attr{Key: k, Value: v} } + // Span is a single tracing span. type Span interface { End() diff --git a/Server/ws/authz_test.go b/Server/ws/authz_test.go index c628132f..ef157e7d 100644 --- a/Server/ws/authz_test.go +++ b/Server/ws/authz_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" "time" @@ -29,7 +30,7 @@ func channelFocusMsg(channelID int64) []byte { // specific role on a specific channel. func denyReadOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, channelID, roleID, permissions.ReadMessages, ) diff --git a/Server/ws/can_send_ready_test.go b/Server/ws/can_send_ready_test.go index a527437e..bcffdcc4 100644 --- a/Server/ws/can_send_ready_test.go +++ b/Server/ws/can_send_ready_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" ) @@ -10,11 +11,11 @@ import ( func TestBuildReady_IncludesCanSend(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "cansend-user") - role, err := database.GetRoleByID(1) + role, err := database.GetRoleByID(context.Background(), 1) if err != nil || role == nil { t.Fatalf("GetRoleByID: %v", err) } - if _, err := database.CreateChannel("general", "text", "", "", 0); err != nil { + if _, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0); err != nil { t.Fatalf("CreateChannel: %v", err) } msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role) diff --git a/Server/ws/channel_visibility_agreement_test.go b/Server/ws/channel_visibility_agreement_test.go index 603aa20e..c66eeea8 100644 --- a/Server/ws/channel_visibility_agreement_test.go +++ b/Server/ws/channel_visibility_agreement_test.go @@ -21,10 +21,10 @@ import ( func seedVisibilityUser(t *testing.T, database *db.DB, username string, roleID int) *db.User { t.Helper() - if _, err := database.CreateUser(username, "hash", roleID); err != nil { + if _, err := database.CreateUser(context.Background(), username, "hash", roleID); err != nil { t.Fatalf("CreateUser(%s): %v", username, err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("GetUserByUsername(%s): %v", username, err) } @@ -55,19 +55,19 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) { svc := service.New(database, limiter) // Seed one channel of each server type plus a dm channel (never visible). - textID, err := database.CreateChannel("general", "text", "", "", 0) + textID, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel text: %v", err) } - annID, err := database.CreateChannel("announce", "announcement", "", "", 1) + annID, err := database.CreateChannel(context.Background(), "announce", "announcement", "", "", 1) if err != nil { t.Fatalf("CreateChannel announcement: %v", err) } - voiceID, err := database.CreateChannel("voice", "voice", "", "", 2) + voiceID, err := database.CreateChannel(context.Background(), "voice", "voice", "", "", 2) if err != nil { t.Fatalf("CreateChannel voice: %v", err) } - dmID, err := database.CreateChannel("dm", "dm", "", "", 3) + dmID, err := database.CreateChannel(context.Background(), "dm", "dm", "", "", 3) if err != nil { t.Fatalf("CreateChannel dm: %v", err) } @@ -81,12 +81,12 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) { ) // Member is denied READ on the announcement channel only. - if err := database.UpsertChannelOverride(annID, roleMember, 0, permissions.ReadMessages); err != nil { + if err := database.UpsertChannelOverride(context.Background(), annID, roleMember, 0, permissions.ReadMessages); err != nil { t.Fatalf("UpsertChannelOverride member/announcement: %v", err) } // Moderator is denied READ on every server channel → sees nothing. for _, chID := range []int64{textID, annID, voiceID} { - if err := database.UpsertChannelOverride(chID, roleModerator, 0, permissions.ReadMessages); err != nil { + if err := database.UpsertChannelOverride(context.Background(), chID, roleModerator, 0, permissions.ReadMessages); err != nil { t.Fatalf("UpsertChannelOverride moderator/%d: %v", chID, err) } } @@ -104,7 +104,7 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { user := seedVisibilityUser(t, database, "vis-"+tc.name, tc.roleID) - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(context.Background(), user.RoleID) if err != nil || role == nil { t.Fatalf("GetRoleByID: %v", err) } diff --git a/Server/ws/coverage_boost2_test.go b/Server/ws/coverage_boost2_test.go index ebbea0cc..14877dfc 100644 --- a/Server/ws/coverage_boost2_test.go +++ b/Server/ws/coverage_boost2_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" "time" @@ -291,8 +292,8 @@ func TestHandleVoiceScreenshare_NotInVoice2(t *testing.T) { func TestHandleVoiceMute_BadPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "mute-bad-payload") - chanID, _ := database.CreateChannel("mute-bp-ch", "voice", "", "", 0) - _ = database.JoinVoiceChannel(user.ID, chanID) + chanID, _ := database.CreateChannel(context.Background(), "mute-bp-ch", "voice", "", "", 0) + _ = database.JoinVoiceChannel(context.Background(), user.ID, chanID) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) @@ -322,8 +323,8 @@ func TestHandleVoiceMute_BadPayload(t *testing.T) { func TestHandleVoiceDeafen_BadPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "deafen-bad-payload") - chanID, _ := database.CreateChannel("deafen-bp-ch", "voice", "", "", 0) - _ = database.JoinVoiceChannel(user.ID, chanID) + chanID, _ := database.CreateChannel(context.Background(), "deafen-bp-ch", "voice", "", "", 0) + _ = database.JoinVoiceChannel(context.Background(), user.ID, chanID) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) @@ -355,8 +356,8 @@ func TestHandleVoiceDeafen_BadPayload(t *testing.T) { func TestHandleVoiceCamera_BadPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "cam-bad-payload") - chanID, _ := database.CreateChannel("cam-bp-ch", "voice", "", "", 0) - _ = database.JoinVoiceChannel(user.ID, chanID) + chanID, _ := database.CreateChannel(context.Background(), "cam-bp-ch", "voice", "", "", 0) + _ = database.JoinVoiceChannel(context.Background(), user.ID, chanID) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) @@ -389,8 +390,8 @@ func TestHandleVoiceCamera_BadPayload(t *testing.T) { func TestHandleVoiceScreenshare_BadPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "share-bad-payload") - chanID, _ := database.CreateChannel("share-bp-ch", "voice", "", "", 0) - _ = database.JoinVoiceChannel(user.ID, chanID) + chanID, _ := database.CreateChannel(context.Background(), "share-bp-ch", "voice", "", "", 0) + _ = database.JoinVoiceChannel(context.Background(), user.ID, chanID) send := make(chan []byte, 16) c := ws.NewTestClientWithUser(hub, user, 0, send) diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index 691aa621..f93112c3 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -4,6 +4,7 @@ package ws_test // to push the ws package above 80%. import ( + "context" "encoding/json" "math" "strings" @@ -100,11 +101,11 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) { func seedCoverageOwner(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 1) + _, err := database.CreateUser(context.Background(), username, "hash", 1) if err != nil { t.Fatalf("seedCoverageOwner CreateUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedCoverageOwner GetUserByUsername: %v", err) } @@ -488,20 +489,20 @@ func TestHandleMessage_Ping_ReturnsPong(t *testing.T) { func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "ready-voice-user") - role, rErr := database.GetRoleByID(1) + role, rErr := database.GetRoleByID(context.Background(), 1) if rErr != nil || role == nil { t.Fatalf("GetRoleByID: %v", rErr) } // Create a voice channel. - vcID, err := database.CreateChannel("voice-room", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel voice: %v", err) } // Create another user and join them to voice. other := seedCoverageOwner(t, database, "ready-voice-other") - if err := database.JoinVoiceChannel(other.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), other.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } @@ -529,17 +530,17 @@ func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) { func TestBuildReady_MultipleChannelTypes(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "ready-multi-user") - role, rErr := database.GetRoleByID(1) + role, rErr := database.GetRoleByID(context.Background(), 1) if rErr != nil || role == nil { t.Fatalf("GetRoleByID: %v", rErr) } // Create text and voice channels. - _, err := database.CreateChannel("text-chan", "text", "General", "", 0) + _, err := database.CreateChannel(context.Background(), "text-chan", "text", "General", "", 0) if err != nil { t.Fatalf("CreateChannel text: %v", err) } - _, err = database.CreateChannel("voice-chan", "voice", "General", "", 1) + _, err = database.CreateChannel(context.Background(), "voice-chan", "voice", "General", "", 1) if err != nil { t.Fatalf("CreateChannel voice: %v", err) } @@ -695,7 +696,7 @@ func TestHandleVoiceCamera_NotInVoice(t *testing.T) { func TestHandleVoiceCamera_InvalidPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "vc-bad-payload") - vcID, err := database.CreateChannel("cam-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "cam-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -746,7 +747,7 @@ func TestHandleVoiceScreenshare_NotInVoice(t *testing.T) { func TestHandleVoiceScreenshare_InvalidPayload(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "vs-bad-payload") - vcID, err := database.CreateChannel("screen-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "screen-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -947,11 +948,11 @@ func TestSendToUser_FullBuffer_ReturnsFalse(t *testing.T) { func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) { hub, database := newCoverageHub(t) // Use a member user. - _, err := database.CreateUser("attach-noperm-user", "hash", 4) + _, err := database.CreateUser(context.Background(), "attach-noperm-user", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } - user, err := database.GetUserByUsername("attach-noperm-user") + user, err := database.GetUserByUsername(context.Background(), "attach-noperm-user") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } @@ -959,7 +960,7 @@ func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) { chID := seedTestChannel(t, database, "attach-noperm-chan") // Deny ATTACH_FILES (0x0020) on this channel for Member role (id=4). - _, err = database.Exec("INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID) + _, err = database.ExecContext(context.Background(), "INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID) if err != nil { t.Fatalf("INSERT channel_overrides: %v", err) } @@ -1026,21 +1027,21 @@ func TestHandleChatSend_WithAttachments_Success(t *testing.T) { func TestHandleChatSend_SlowMode_EnforcedForMember(t *testing.T) { hub, database := newCoverageHub(t) - _, err := database.CreateUser("slow-member-user", "hash", 4) + _, err := database.CreateUser(context.Background(), "slow-member-user", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } - user, err := database.GetUserByUsername("slow-member-user") + user, err := database.GetUserByUsername(context.Background(), "slow-member-user") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } // Create channel with slow mode. - chID, err := database.CreateChannel("slow-chan", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "slow-chan", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - if err := database.SetChannelSlowMode(chID, 60); err != nil { + if err := database.SetChannelSlowMode(context.Background(), chID, 60); err != nil { t.Fatalf("SetChannelSlowMode: %v", err) } @@ -1323,7 +1324,7 @@ func TestHandleChannelFocus_UpdatesReadState(t *testing.T) { chID := seedTestChannel(t, database, "cf-readstate-chan") // Insert a message so there's a latest_message_id. - _, err := database.CreateMessage(chID, user.ID, "test message", nil) + _, err := database.CreateMessage(context.Background(), chID, user.ID, "test message", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -1404,7 +1405,7 @@ func drainChanTimeout(ch <-chan []byte, d time.Duration) [][]byte { func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "voice", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel voice: %v", err) } @@ -1532,11 +1533,11 @@ func TestHandleVoiceJoin_SwitchChannels(t *testing.T) { func TestHandleVoiceJoin_ChannelFull(t *testing.T) { hub, database := newCoverageHub(t) - vcID, err := database.CreateChannel("full-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "full-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - _, err = database.Exec("UPDATE channels SET voice_max_users = 1 WHERE id = ?", vcID) + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_max_users = 1 WHERE id = ?", vcID) if err != nil { t.Fatalf("UPDATE channels: %v", err) } @@ -1740,11 +1741,11 @@ func TestHandleVoiceJoin_WithQualityOverride(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "vj-quality-user") - vcID, err := database.CreateChannel("quality-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "quality-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - _, err = database.Exec("UPDATE channels SET voice_quality = 'high' WHERE id = ?", vcID) + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'high' WHERE id = ?", vcID) if err != nil { t.Fatalf("UPDATE: %v", err) } @@ -2043,11 +2044,11 @@ func TestBuildAuthOK_NonNilAvatar(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "authok-avatar-user") // Set a non-nil avatar. - _, err := database.Exec("UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID) + _, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID) if err != nil { t.Fatalf("UPDATE avatar: %v", err) } - user, err = database.GetUserByUsername("authok-avatar-user") + user, err = database.GetUserByUsername(context.Background(), "authok-avatar-user") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } @@ -2072,12 +2073,12 @@ func TestHandleChatSend_WithNonNilAvatar(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "avatar-user") // Set a non-nil avatar on the user. - _, err := database.Exec("UPDATE users SET avatar = 'https://example.com/avatar.png' WHERE id = ?", user.ID) + _, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/avatar.png' WHERE id = ?", user.ID) if err != nil { t.Fatalf("UPDATE avatar: %v", err) } // Reload user to get updated avatar. - user, err = database.GetUserByUsername("avatar-user") + user, err = database.GetUserByUsername(context.Background(), "avatar-user") if err != nil || user == nil { t.Fatalf("GetUserByUsername: %v", err) } @@ -2218,11 +2219,11 @@ func TestHandleVoiceJoin_InvalidQualityFallsBackToMedium(t *testing.T) { hub, database := newCoverageHub(t) user := seedCoverageOwner(t, database, "vj-badquality-user") - vcID, err := database.CreateChannel("badquality-vc", "voice", "", "", 0) + vcID, err := database.CreateChannel(context.Background(), "badquality-vc", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } - _, err = database.Exec("UPDATE channels SET voice_quality = 'garbage' WHERE id = ?", vcID) + _, err = database.ExecContext(context.Background(), "UPDATE channels SET voice_quality = 'garbage' WHERE id = ?", vcID) if err != nil { t.Fatalf("UPDATE: %v", err) } @@ -2434,7 +2435,7 @@ func TestRollbackVoiceJoin_ClearsVoiceStateAndBroadcasts(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } ws.SetVoiceChIDForTest(c, vcID) @@ -2446,7 +2447,7 @@ func TestRollbackVoiceJoin_ClearsVoiceStateAndBroadcasts(t *testing.T) { t.Fatalf("voiceChID after rollback = %d, want 0", got) } - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Fatal("voice state should be nil after rollback") } @@ -2489,11 +2490,11 @@ func TestLeaveVoiceChannelWithRetry_SuccessOnFirstAttempt(t *testing.T) { user := seedCoverageOwner(t, database, "lvcr-ok") vcID := seedVoiceChannel(t, database, "lvcr-ok-vc") - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state == nil { t.Fatal("voice state should exist before leave") } @@ -2503,7 +2504,7 @@ func TestLeaveVoiceChannelWithRetry_SuccessOnFirstAttempt(t *testing.T) { t.Fatalf("leaveVoiceChannelWithRetry returned error: %v", err) } - state, _ = database.GetVoiceState(user.ID) + state, _ = database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Fatal("voice state should be nil after successful leave") } @@ -2535,10 +2536,10 @@ func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { hub.Register(c2) time.Sleep(20 * time.Millisecond) - if err := database.JoinVoiceChannel(user1.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user1.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel u1: %v", err) } - if err := database.JoinVoiceChannel(user2.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user2.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel u2: %v", err) } ws.SetVoiceChIDForTest(c1, vcID) @@ -2554,7 +2555,7 @@ func TestCleanupVoiceForChannel_WithClientsInChannel(t *testing.T) { t.Errorf("c2 voiceChID = %d, want 0", got) } - states, _ := database.GetChannelVoiceStates(vcID) + states, _ := database.GetChannelVoiceStates(context.Background(), vcID) if len(states) != 0 { t.Errorf("expected 0 voice states after cleanup, got %d", len(states)) } @@ -2567,7 +2568,7 @@ func TestCleanupVoiceForChannel_EmptyChannel(t *testing.T) { time.Sleep(20 * time.Millisecond) // After cleanup of an empty channel, voice states should still be empty. - states, err := database.GetChannelVoiceStates(vcID) + states, err := database.GetChannelVoiceStates(context.Background(), vcID) if err != nil { t.Fatalf("GetChannelVoiceStates: %v", err) } @@ -2581,14 +2582,14 @@ func TestCleanupVoiceForChannel_DBStateButNoClient(t *testing.T) { user := seedCoverageOwner(t, database, "cvfc-noclient") vcID := seedVoiceChannel(t, database, "cvfc-noclient-vc") - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } hub.CleanupVoiceForChannel(vcID) time.Sleep(50 * time.Millisecond) - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Error("voice state should be nil after cleanup") } @@ -2602,12 +2603,12 @@ func TestSweepStaleVoiceStates_RemovesGhostState(t *testing.T) { vcID := seedVoiceChannel(t, database, "sweep-ghost-vc") // Put user in voice in DB but don't register a client — ghost state. - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } // Verify it exists. - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state == nil { t.Fatal("voice state should exist before sweep") } @@ -2616,7 +2617,7 @@ func TestSweepStaleVoiceStates_RemovesGhostState(t *testing.T) { time.Sleep(100 * time.Millisecond) // Ghost state should be removed. - state, _ = database.GetVoiceState(user.ID) + state, _ = database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Error("ghost voice state should be nil after sweep") } @@ -2633,7 +2634,7 @@ func TestSweepStaleVoiceStates_PreservesActiveClientState(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - if err := database.JoinVoiceChannel(user.ID, vcID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vcID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } ws.SetVoiceChIDForTest(c, vcID) @@ -2642,7 +2643,7 @@ func TestSweepStaleVoiceStates_PreservesActiveClientState(t *testing.T) { time.Sleep(100 * time.Millisecond) // Active client's state should be preserved. - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state == nil { t.Error("active client's voice state should be preserved after sweep") } @@ -2656,7 +2657,7 @@ func TestSweepStaleVoiceStates_NoStatesNoPanic(t *testing.T) { // With no voice states in the DB, sweep should leave the system clean. // Verify by checking a known user has no voice state. user := seedCoverageOwner(t, database, "sweep-no-states") - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -2677,7 +2678,7 @@ func TestSweepStaleVoiceStates_MismatchedChannelIsGhost(t *testing.T) { hub.Register(c) time.Sleep(20 * time.Millisecond) - if err := database.JoinVoiceChannel(user.ID, vc2); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, vc2); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } ws.SetVoiceChIDForTest(c, vc1) // Client thinks vc1, DB says vc2 — mismatch. @@ -2686,7 +2687,7 @@ func TestSweepStaleVoiceStates_MismatchedChannelIsGhost(t *testing.T) { time.Sleep(100 * time.Millisecond) // Mismatched state should be removed from DB. - state, _ := database.GetVoiceState(user.ID) + state, _ := database.GetVoiceState(context.Background(), user.ID) if state != nil { t.Error("mismatched voice state should be removed after sweep") } diff --git a/Server/ws/deps.go b/Server/ws/deps.go index 19afa147..f99a17e9 100644 --- a/Server/ws/deps.go +++ b/Server/ws/deps.go @@ -88,7 +88,7 @@ type VoiceDeps struct { // permission bit is genuinely absent from the user's role). Previously // every branch returned FORBIDDEN, which hid operator-visible failures // behind a user-facing permission denial. -func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, perm int64, label string) *Result { +func requirePerm(ctx context.Context, database *db.DB, perms *permissions.Checker, userID, channelID, perm int64, label string) *Result { if database == nil || perms == nil { // Missing dependency is a server bug, not a user ACL outcome. Log // here so operators see something even when the client surfaces a @@ -98,7 +98,7 @@ func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, r := Result{Error: ClientError{Code: ErrCodeInternal, Message: "permission check unavailable"}} return &r } - role, err := database.GetRoleForUser(userID) + role, err := database.GetRoleForUser(ctx, userID) if err != nil { slog.Error("ws: requirePerm GetRoleForUser failed", "user_id", userID, "channel_id", channelID, "err", err) @@ -110,7 +110,7 @@ func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}} return &r } - if !perms.HasChannelPerm(role.Permissions, role.ID, channelID, perm) { + if !perms.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) { r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "missing " + label + " permission"}} return &r } @@ -118,15 +118,15 @@ func requirePerm(database *db.DB, perms *permissions.Checker, userID, channelID, } // hasPerm checks a channel permission via DB lookups. Returns true if allowed. -func hasPerm(database *db.DB, perms *permissions.Checker, userID, channelID, perm int64) bool { +func hasPerm(ctx context.Context, database *db.DB, perms *permissions.Checker, userID, channelID, perm int64) bool { if database == nil || perms == nil { return false } - role, err := database.GetRoleForUser(userID) + role, err := database.GetRoleForUser(ctx, userID) if err != nil || role == nil { return false } - return perms.HasChannelPerm(role.Permissions, role.ID, channelID, perm) + return perms.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) } // ── V2 handler type ───────────────────────────────────────────────────────── diff --git a/Server/ws/dm_handlers_test.go b/Server/ws/dm_handlers_test.go index cdbf6153..e2b40f9e 100644 --- a/Server/ws/dm_handlers_test.go +++ b/Server/ws/dm_handlers_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "fmt" "testing" @@ -15,7 +16,7 @@ import ( // seedDMChannel creates a DM channel between two users and returns the channel ID. func seedDMChannel(t *testing.T, database *db.DB, user1ID, user2ID int64) int64 { t.Helper() - ch, _, err := database.GetOrCreateDMChannel(user1ID, user2ID) + ch, _, err := database.GetOrCreateDMChannel(context.Background(), user1ID, user2ID) if err != nil { t.Fatalf("seedDMChannel: %v", err) } @@ -244,7 +245,7 @@ func TestDM_ChatSend_AutoReopenForRecipient(t *testing.T) { dmChID := seedDMChannel(t, database, alice.ID, bob.ID) // Bob closes the DM. - if err := database.CloseDM(bob.ID, dmChID); err != nil { + if err := database.CloseDM(context.Background(), bob.ID, dmChID); err != nil { t.Fatalf("CloseDM: %v", err) } @@ -281,7 +282,7 @@ func TestDM_ChatEdit_ParticipantCanEdit(t *testing.T) { dmChID := seedDMChannel(t, database, alice.ID, bob.ID) // Create a message directly in the DB. - msgID, err := database.CreateMessage(dmChID, alice.ID, "original", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "original", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -310,7 +311,7 @@ func TestDM_ChatEdit_NonParticipantForbidden(t *testing.T) { dmChID := seedDMChannel(t, database, alice.ID, bob.ID) // Alice creates a message. - msgID, err := database.CreateMessage(dmChID, alice.ID, "private", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "private", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -338,7 +339,7 @@ func TestDM_ChatDelete_ParticipantCanDeleteOwn(t *testing.T) { bob := seedMemberUser(t, database, "dm-del-bob") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, alice.ID, "to delete", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "to delete", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -372,7 +373,7 @@ func TestDM_ChatDelete_NonParticipantForbidden(t *testing.T) { charlie := seedMemberUser(t, database, "dm-delforbid-charlie") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, alice.ID, "protected", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "protected", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -400,7 +401,7 @@ func TestDM_ChatDelete_NoModeratorOverride(t *testing.T) { dmChID := seedDMChannel(t, database, alice.ID, bob.ID) // Bob's message. - msgID, err := database.CreateMessage(dmChID, bob.ID, "bob says hi", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, bob.ID, "bob says hi", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -540,7 +541,7 @@ func TestDM_ReactionAdd_ParticipantSuccess(t *testing.T) { bob := seedMemberUser(t, database, "dm-react-bob") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, alice.ID, "react to me", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "react to me", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -574,7 +575,7 @@ func TestDM_ReactionAdd_NonParticipantError(t *testing.T) { charlie := seedMemberUser(t, database, "dm-reactforbid-charlie") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, alice.ID, "private msg", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, alice.ID, "private msg", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } @@ -601,7 +602,7 @@ func TestDM_ReactionRemove_ParticipantSuccess(t *testing.T) { bob := seedMemberUser(t, database, "dm-reactrm-bob") dmChID := seedDMChannel(t, database, alice.ID, bob.ID) - msgID, err := database.CreateMessage(dmChID, bob.ID, "remove reaction", nil) + msgID, err := database.CreateMessage(context.Background(), dmChID, bob.ID, "remove reaction", nil) if err != nil { t.Fatalf("CreateMessage: %v", err) } diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 9a115869..16ba1aaf 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -147,7 +147,7 @@ func TouchForTest(c *Client) { // RollbackVoiceJoinForTest exposes Hub.rollbackVoiceJoin for external tests. func (h *Hub) RollbackVoiceJoinForTest(c *Client, channelID int64) { - h.rollbackVoiceJoin(c, channelID, true) + h.rollbackVoiceJoin(context.Background(), c, channelID, true) } // LeaveVoiceChannelWithRetryForTest exposes leaveVoiceChannelWithRetry for external tests. @@ -194,29 +194,29 @@ func (h *Hub) PubSubForTest() *PubSub { // Defaults to replay_source="none" since most callers test the fresh-connect // path; tests that care about the resume tier can call buildAuthOK directly. func (h *Hub) BuildAuthOKForTest(user *db.User, roleName string) []byte { - return h.buildAuthOK(user, roleName, "none") + return h.buildAuthOK(context.Background(), user, roleName, "none") } // BuildReadyForTest exposes Hub.buildReady for external tests. // Passes nil role so no channels are visible (fail-closed, BUG-094). func (h *Hub) BuildReadyForTest(database *db.DB, userID int64) ([]byte, error) { - return h.buildReady(database, userID, nil) + return h.buildReady(context.Background(), database, userID, nil) } // BuildReadyWithRoleForTest exposes Hub.buildReady with a role for external tests. func (h *Hub) BuildReadyWithRoleForTest(database *db.DB, userID int64, role *db.Role) ([]byte, error) { - return h.buildReady(database, userID, role) + return h.buildReady(context.Background(), database, userID, role) } // ComputeAllowedChannelsForTest exposes Hub.computeAllowedChannels for external // tests (the REST/WS channel-visibility agreement test). func (h *Hub) ComputeAllowedChannelsForTest(database *db.DB, user *db.User) (map[int64]bool, error) { - return h.computeAllowedChannels(database, user) + return h.computeAllowedChannels(context.Background(), database, user) } // GetCachedSettingsForTest exposes Hub.getCachedSettings for external tests. func (h *Hub) GetCachedSettingsForTest() (string, string) { - return h.getCachedSettings() + return h.getCachedSettings(context.Background()) } // GetClientVoiceChIDForTest exposes Client.getVoiceChID for external tests. @@ -316,5 +316,5 @@ func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool { // HasChannelPermForTest exposes Hub.hasChannelPerm for external tests. func (h *Hub) HasChannelPermForTest(c *Client, channelID, perm int64) bool { - return h.hasChannelPerm(c, channelID, perm) + return h.hasChannelPerm(context.Background(), c, channelID, perm) } diff --git a/Server/ws/handler_v2_channel_focus_test.go b/Server/ws/handler_v2_channel_focus_test.go index d44f1b2c..136ab048 100644 --- a/Server/ws/handler_v2_channel_focus_test.go +++ b/Server/ws/handler_v2_channel_focus_test.go @@ -23,11 +23,11 @@ func newFocusTestDeps(t *testing.T) (PresenceDeps, int64, int64) { } t.Cleanup(func() { database.Close() }) - userID, err := database.CreateUser("focuser", "hash", 1) // Owner role + userID, err := database.CreateUser(context.Background(), "focuser", "hash", 1) // Owner role if err != nil { t.Fatalf("CreateUser: %v", err) } - chID, err := database.CreateChannel("focus-chan", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "focus-chan", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -98,11 +98,11 @@ func TestChannelFocusV2_NoPermission_ReturnsForbidden(t *testing.T) { t.Cleanup(func() { database.Close() }) // Use Member role (id=4) and create a channel with a deny override. - userID, _ := database.CreateUser("noperm", "hash", 4) - chID, _ := database.CreateChannel("restricted", "text", "", "", 0) + userID, _ := database.CreateUser(context.Background(), "noperm", "hash", 4) + chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0) // Deny READ_MESSAGES for Member role on this channel via raw SQL. - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, ?)`, chID, permissions.ReadMessages, ) diff --git a/Server/ws/handler_v2_migration_test.go b/Server/ws/handler_v2_migration_test.go index d41bbc6b..b2fef015 100644 --- a/Server/ws/handler_v2_migration_test.go +++ b/Server/ws/handler_v2_migration_test.go @@ -140,7 +140,7 @@ func TestHandleChatCommandV2_NoRegistry(t *testing.T) { // canPluginBroadcast fails closed when the posting-gate service is absent. func TestCanPluginBroadcast_NilServiceFailsClosed(t *testing.T) { - gate := canPluginBroadcast(nil, 1, 2) + gate := canPluginBroadcast(context.Background(), nil, 1, 2) if gate == nil { t.Fatal("expected a forbidden Result when MessageSvc is nil") } diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 92e621b0..6aad9065 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -37,7 +37,7 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { c.mu.Unlock() if shouldCheck && c.tokenHash != "" { - result, dbErr := h.db.GetSessionWithBanStatus(c.tokenHash) + result, dbErr := h.db.GetSessionWithBanStatus(c.ctx, c.tokenHash) if dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) { slog.Info("ws session expired, closing connection", "user_id", c.userID) h.kickClient(c) @@ -200,19 +200,19 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { // connection — including the SPEAK/VIDEO grants baked into a freshly minted // LiveKit token — instead of persisting until the user reconnects. This mirrors // the V2 handlers, which already resolve the live role (deps.go). -func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool { - role, err := h.db.GetRoleForUser(c.userID) +func (h *Hub) hasChannelPerm(ctx context.Context, c *Client, channelID int64, perm int64) bool { + role, err := h.db.GetRoleForUser(ctx, c.userID) if err != nil || role == nil { return false } - return h.permChecker.HasChannelPerm(role.Permissions, role.ID, channelID, perm) + return h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, perm) } // requireChannelPerm checks whether the client has the given permission on the // channel. If not, it sends a FORBIDDEN error to the client and returns false. // The permLabel should be the human-readable permission name (e.g. "SEND_MESSAGES"). -func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLabel string) bool { - if h.hasChannelPerm(c, channelID, perm) { +func (h *Hub) requireChannelPerm(ctx context.Context, c *Client, channelID int64, perm int64, permLabel string) bool { + if h.hasChannelPerm(ctx, c, channelID, perm) { return true } slog.Warn("ws permission denied", "user_id", c.userID, "channel_id", channelID, "perm", permLabel) diff --git a/Server/ws/handlers_chat.go b/Server/ws/handlers_chat.go index 02bed897..70b8481c 100644 --- a/Server/ws/handlers_chat.go +++ b/Server/ws/handlers_chat.go @@ -78,11 +78,11 @@ func handleChatSendV2(ctx context.Context, cmd Command, info ClientInfo, deps an } // handleChatEditV2 processes a chat_edit command via the MessageService. -func handleChatEditV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleChatEditV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ChatDeps) editCmd := cmd.(ChatEditCmd) - result, err := d.MessageSvc.EditMessage(info.UserID, editCmd.MessageID(), editCmd.Content()) + result, err := d.MessageSvc.EditMessage(ctx, info.UserID, editCmd.MessageID(), editCmd.Content()) if err != nil { return serviceErrorToResult(err) } @@ -102,11 +102,11 @@ func handleChatEditV2(_ context.Context, cmd Command, info ClientInfo, deps any) } // handleChatDeleteV2 processes a chat_delete command via the MessageService. -func handleChatDeleteV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleChatDeleteV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ChatDeps) deleteCmd := cmd.(ChatDeleteCmd) - result, err := d.MessageSvc.DeleteMessage(info.UserID, deleteCmd.MessageID()) + result, err := d.MessageSvc.DeleteMessage(ctx, info.UserID, deleteCmd.MessageID()) if err != nil { return serviceErrorToResult(err) } diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index 341b8766..aff262fa 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -67,7 +67,7 @@ func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps an // gate denies, the error wins and the ephemeral reply is dropped (Result // carries either an error or a reply, not both) — an untested edge; V1 // sent both. Preserve the security signal (denial) over the ack. - if gate := canPluginBroadcast(d.MessageSvc, cc.userID, cc.channelID); gate != nil { + if gate := canPluginBroadcast(ctx, d.MessageSvc, cc.userID, cc.channelID); gate != nil { return *gate } msg := buildCommandBroadcast(cc.channelID, cc.userID, cc.command, result.Broadcast) @@ -83,12 +83,12 @@ func handleChatCommandV2(ctx context.Context, cmd Command, _ ClientInfo, deps an // allowed, or a Result carrying the appropriate ClientError otherwise. A nil // MessageSvc (bare test hub) fails closed rather than allowing an ungated // broadcast. -func canPluginBroadcast(messageSvc *service.MessageService, userID, channelID int64) *Result { +func canPluginBroadcast(ctx context.Context, messageSvc *service.MessageService, userID, channelID int64) *Result { if messageSvc == nil { r := Result{Error: ClientError{Code: ErrCodeForbidden, Message: "broadcast gate unavailable"}} return &r } - if err := messageSvc.CanPost(userID, channelID); err != nil { + if err := messageSvc.CanPost(ctx, userID, channelID); err != nil { if errors.Is(err, service.ErrNotFound) { r := Result{Error: ClientError{Code: ErrCodeNotFound, Message: "channel not found"}} return &r diff --git a/Server/ws/handlers_presence.go b/Server/ws/handlers_presence.go index 58569a8d..0aae0f7f 100644 --- a/Server/ws/handlers_presence.go +++ b/Server/ws/handlers_presence.go @@ -18,13 +18,13 @@ func registerPresenceHandlers(r *HandlerRegistry, deps PresenceDeps) { // handleTypingV2 is the V2 handler for typing_start messages. // It validates the channel, checks permissions, and returns events to broadcast // the typing indicator to channel members (excluding the sender). -func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleTypingV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(PresenceDeps) typingCmd := cmd.(TypingStartCmd) channelID := typingCmd.ChannelID() userID := info.UserID - ch, err := d.ChannelSvc.HandleTyping(userID, channelID, d.Limiter) + ch, err := d.ChannelSvc.HandleTyping(ctx, userID, channelID, d.Limiter) if err != nil || ch == nil { return Result{} // silently drop } @@ -32,7 +32,7 @@ func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) R payload := buildTypingMsg(channelID, userID, info.Username) if ch.Type == "dm" { - participantIDs, pErr := d.ChannelSvc.GetDMParticipantIDs(channelID) + participantIDs, pErr := d.ChannelSvc.GetDMParticipantIDs(ctx, channelID) if pErr != nil { return Result{} } @@ -62,13 +62,13 @@ func handleTypingV2(_ context.Context, cmd Command, info ClientInfo, deps any) R // handlePresenceV2 is the V2 handler for presence_update messages. // It validates the status, updates the DB, and broadcasts to all clients. -func handlePresenceV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handlePresenceV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(PresenceDeps) presenceCmd := cmd.(PresenceUpdateCmd) userID := info.UserID status := presenceCmd.Status() - if err := d.ChannelSvc.HandlePresenceUpdate(userID, status, d.Limiter); err != nil { + if err := d.ChannelSvc.HandlePresenceUpdate(ctx, userID, status, d.Limiter); err != nil { return serviceErrorToResult(err) } @@ -82,12 +82,12 @@ func handlePresenceV2(_ context.Context, cmd Command, info ClientInfo, deps any) // handleChannelFocusV2 is the V2 handler for channel_focus messages. // It validates permissions, signals the client's focused channel via SetChannelID, // and marks the channel as read. -func handleChannelFocusV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleChannelFocusV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(PresenceDeps) focusCmd := cmd.(ChannelFocusCmd) chID := focusCmd.ChannelID() - _, err := d.ChannelSvc.HandleChannelFocus(info.UserID, chID) + _, err := d.ChannelSvc.HandleChannelFocus(ctx, info.UserID, chID) if err != nil { if errors.Is(err, service.ErrForbidden) { return Result{Error: ClientError{Code: ErrCodeForbidden, Message: "access denied"}} diff --git a/Server/ws/handlers_reaction.go b/Server/ws/handlers_reaction.go index fe5053fa..8f75b159 100644 --- a/Server/ws/handlers_reaction.go +++ b/Server/ws/handlers_reaction.go @@ -15,7 +15,7 @@ func registerReactionHandlers(r *HandlerRegistry, deps ReactionDeps) { // reactionV2Handler returns a V2 handler for reaction_add (add=true) or // reaction_remove (add=false). Both share identical validation and routing. func reactionV2Handler(add bool) HandlerV2 { - return func(_ context.Context, cmd Command, info ClientInfo, deps any) Result { + return func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(ReactionDeps) userID := info.UserID @@ -34,9 +34,9 @@ func reactionV2Handler(add bool) HandlerV2 { var result *service.ReactionResult var err error if add { - result, err = d.MessageSvc.AddReaction(userID, msgID, emoji) + result, err = d.MessageSvc.AddReaction(ctx, userID, msgID, emoji) } else { - result, err = d.MessageSvc.RemoveReaction(userID, msgID, emoji) + result, err = d.MessageSvc.RemoveReaction(ctx, userID, msgID, emoji) } if err != nil { return serviceErrorToResult(err) diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 688a81c8..e253eb66 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "fmt" "testing" @@ -85,11 +86,11 @@ func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) { // includes MANAGE_MESSAGES bit 0x10000). func seedModUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 3) // roleID=3 → Moderator + _, err := database.CreateUser(context.Background(), username, "hash", 3) // roleID=3 → Moderator if err != nil { t.Fatalf("seedModUser CreateUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedModUser GetUserByUsername: %v", err) } @@ -100,11 +101,11 @@ func seedModUser(t *testing.T, database *db.DB, username string) *db.User { // does NOT have MANAGE_MESSAGES (0x10000=65536). func seedMemberUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 4) // roleID=4 → Member + _, err := database.CreateUser(context.Background(), username, "hash", 4) // roleID=4 → Member if err != nil { t.Fatalf("seedMemberUser CreateUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedMemberUser GetUserByUsername: %v", err) } @@ -115,12 +116,12 @@ func seedMemberUser(t *testing.T, database *db.DB, username string) *db.User { // given seconds value, then returns the channel ID. func seedChannelWithSlowMode(t *testing.T, database *db.DB, name string, slowModeSecs int) int64 { t.Helper() - chID, err := database.CreateChannel(name, "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), name, "text", "", "", 0) if err != nil { t.Fatalf("seedChannelWithSlowMode CreateChannel: %v", err) } if slowModeSecs > 0 { - if err := database.SetChannelSlowMode(chID, slowModeSecs); err != nil { + if err := database.SetChannelSlowMode(context.Background(), chID, slowModeSecs); err != nil { t.Fatalf("seedChannelWithSlowMode SetChannelSlowMode: %v", err) } } @@ -194,7 +195,7 @@ func TestSessionExpiry_ValidSessionAllowsMessages(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } hash := auth.HashToken(token) - if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -228,11 +229,11 @@ func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } hash := auth.HashToken(token) - if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Delete the session to simulate it being expired/revoked. - if err := database.DeleteSession(hash); err != nil { + if err := database.DeleteSession(context.Background(), hash); err != nil { t.Fatalf("DeleteSession: %v", err) } @@ -501,7 +502,7 @@ func chatSendMsgWithAttachments(channelID int64, content string, attachmentIDs [ // denyAttachOnChannel inserts a channel_override that denies ATTACH_FILES. func denyAttachOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) { t.Helper() - _, err := database.Exec( + _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, channelID, roleID, permissions.AttachFiles, ) @@ -536,7 +537,7 @@ func TestChatSend_AttachmentsDeniedNoMessageCreated(t *testing.T) { // Verify no message was persisted in the database. var count int - err := database.QueryRow("SELECT COUNT(*) FROM messages WHERE channel_id = ?", chID).Scan(&count) + err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM messages WHERE channel_id = ?", chID).Scan(&count) if err != nil { t.Fatalf("count query: %v", err) } @@ -739,7 +740,7 @@ func TestChatSend_SuccessWithReplyTo(t *testing.T) { hub, database := newHandlerHub(t) user := seedOwnerUser(t, database, "send-reply1") chID := seedTestChannel(t, database, "send-reply-chan") - parentMsgID, err := database.CreateMessage(chID, user.ID, "parent message", nil) + parentMsgID, err := database.CreateMessage(context.Background(), chID, user.ID, "parent message", nil) if err != nil { t.Fatalf("CreateMessage parent: %v", err) } @@ -851,7 +852,7 @@ func TestPresence_RateLimit_ReturnsError(t *testing.T) { // and returns its ID. func seedMessage(t *testing.T, database *db.DB, channelID, userID int64, content string) int64 { t.Helper() - id, err := database.CreateMessage(channelID, userID, content, nil) + id, err := database.CreateMessage(context.Background(), channelID, userID, content, nil) if err != nil { t.Fatalf("seedMessage CreateMessage: %v", err) } @@ -1277,7 +1278,7 @@ func TestChatEdit_DeletedMessage_ReturnsForbidden(t *testing.T) { msgID := seedMessage(t, database, chID, user.ID, "to be deleted") // Soft-delete the message. - if err := database.DeleteMessage(msgID, user.ID, false); err != nil { + if err := database.DeleteMessage(context.Background(), msgID, user.ID, false); err != nil { t.Fatalf("DeleteMessage: %v", err) } @@ -1331,7 +1332,7 @@ func TestReaction_RemoveReaction_BroadcastsReactionUpdate(t *testing.T) { msgID := seedMessage(t, database, chID, user.ID, "react to me 2") // Pre-seed the reaction so removal has something to remove. - if err := database.AddReaction(msgID, user.ID, "❤️"); err != nil { + if err := database.AddReaction(context.Background(), msgID, user.ID, "❤️"); err != nil { t.Fatalf("seedReaction: %v", err) } @@ -1524,7 +1525,7 @@ func TestReaction_DeletedMessage_ReturnsBadRequest(t *testing.T) { msgID := seedMessage(t, database, chID, user.ID, "to be deleted") // Soft-delete the message. - if err := database.DeleteMessage(msgID, user.ID, false); err != nil { + if err := database.DeleteMessage(context.Background(), msgID, user.ID, false); err != nil { t.Fatalf("DeleteMessage: %v", err) } @@ -1960,12 +1961,12 @@ func TestHandleMessage_BannedUser_GetKickedAfterSessionCheck(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } hash := auth.HashToken(token) - if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Ban the user in the database (permanent ban, no expiry). - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `UPDATE users SET banned=1, ban_reason='test ban', ban_expires=NULL WHERE id=?`, user.ID, ); err != nil { diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 925b252b..c9eb684b 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -157,12 +157,12 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * KeyHolder: h, }) - h.refreshSettingsLocked() + h.refreshSettingsLocked(context.Background()) return h } // getCachedSettings returns server_name and motd, refreshing the cache if stale. -func (h *Hub) getCachedSettings() (string, string) { +func (h *Hub) getCachedSettings(ctx context.Context) (string, string) { h.settingsMu.RLock() if time.Since(h.settingsLastUpdate) < settingsCacheTTL { name, motd := h.settingsName, h.settingsMotd @@ -177,20 +177,24 @@ func (h *Hub) getCachedSettings() (string, string) { if time.Since(h.settingsLastUpdate) < settingsCacheTTL { return h.settingsName, h.settingsMotd } - h.refreshSettingsLocked() + h.refreshSettingsLocked(ctx) return h.settingsName, h.settingsMotd } // refreshSettingsLocked reloads server_name and motd from the DB. // Caller must hold settingsMu (write lock) or call during init. -func (h *Hub) refreshSettingsLocked() { +func (h *Hub) refreshSettingsLocked(ctx context.Context) { if h.db == nil { return } - if name, err := h.db.GetSetting("server_name"); err == nil { + // The refresh serves the hub-wide settings cache, not the connection that + // happened to trigger it — a dying connection's ctx must not fail the + // fetches (the TTL stamp below would then pin stale values for 30s). + ctx = context.WithoutCancel(ctx) + if name, err := h.db.GetSetting(ctx, "server_name"); err == nil { h.settingsName = name } - if motd, err := h.db.GetSetting("motd"); err == nil { + if motd, err := h.db.GetSetting(ctx, "motd"); err == nil { h.settingsMotd = motd } h.settingsLastUpdate = time.Now() @@ -357,8 +361,10 @@ func (h *Hub) GracefulStop() { // CleanupVoiceForChannel removes all voice participants from the given channel. // Called when a channel is deleted. func (h *Hub) CleanupVoiceForChannel(channelID int64) { + // Cleanup must complete even if the triggering request goes away. + ctx := context.Background() // Get all users in the channel's voice state from DB. - states, err := h.db.GetChannelVoiceStates(channelID) + states, err := h.db.GetChannelVoiceStates(ctx, channelID) if err != nil { slog.Error("CleanupVoiceForChannel GetChannelVoiceStates", "err", err, "channel_id", channelID) return @@ -369,7 +375,7 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) { // Clean up DB state and LiveKit for each participant. for _, vs := range states { - if err := h.db.LeaveVoiceChannel(vs.UserID); err != nil { + if err := h.db.LeaveVoiceChannel(ctx, vs.UserID); err != nil { slog.Error("CleanupVoiceForChannel LeaveVoiceChannel", "err", err, "user_id", vs.UserID, "channel_id", channelID) } @@ -382,7 +388,7 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) { // Remove from LiveKit (best-effort). if h.livekit != nil { - _ = h.livekit.RemoveParticipant(channelID, vs.UserID, vs.JoinedAt) + _ = h.livekit.RemoveParticipant(ctx, channelID, vs.UserID, vs.JoinedAt) } } @@ -555,6 +561,10 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { } h.mu.RUnlock() + // Called via the admin HubBroadcaster interface, which carries no context; + // the targeted re-sync must complete regardless of the triggering request. + ctx := context.Background() + // Visibility is a function of the role, so resolve each role once. visibleByRole := make(map[int64]bool) roleVisible := func(roleID int64) bool { @@ -562,12 +572,12 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { return v } visible := false - role, err := h.db.GetRoleByID(roleID) + role, err := h.db.GetRoleByID(ctx, roleID) if err == nil && role != nil { // Single visibility predicate shared with buildReady / REST // ListVisibleChannels; the checker fails closed on a lookup error // and bypasses for admins, matching the other sites exactly. - visible = h.permChecker.HasChannelPerm(role.Permissions, roleID, ch.ID, permissions.ReadMessages) + visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, ch.ID, permissions.ReadMessages) } visibleByRole[roleID] = visible return visible @@ -580,7 +590,7 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { // c.user is a connect-time snapshot; an admin may have changed the // user's role mid-session, so resolve the current role from the DB. // Fail closed: on error send nothing rather than mis-target. - fresh, err := h.db.GetUserByID(c.user.ID) + fresh, err := h.db.GetUserByID(ctx, c.user.ID) if err != nil || fresh == nil { slog.Warn("hub: RefreshChannelVisibility could not resolve user role", "user_id", c.user.ID, "err", err) @@ -922,6 +932,8 @@ func (h *Hub) sweepRevokedSessions() { if h.db == nil { return } + // Hub run-loop sweeper — no request tie. + ctx := context.Background() h.mu.RLock() snapshot := make([]*Client, 0, len(h.clients)) @@ -933,7 +945,7 @@ func (h *Hub) sweepRevokedSessions() { h.mu.RUnlock() for _, c := range snapshot { - result, err := h.db.GetSessionWithBanStatus(c.tokenHash) + result, err := h.db.GetSessionWithBanStatus(ctx, c.tokenHash) if err != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) { slog.Info("session sweep: revoked/expired session, disconnecting", "user_id", c.userID) @@ -958,7 +970,9 @@ func (h *Hub) sweepStaleVoiceStates() { if h.db == nil { return } - allStates, err := h.db.GetAllVoiceStates() + // Hub run-loop sweeper — no request tie. + ctx := context.Background() + allStates, err := h.db.GetAllVoiceStates(ctx) if err != nil { slog.Warn("sweepStaleVoiceStates: GetAllVoiceStates failed", "err", err) return @@ -989,7 +1003,7 @@ func (h *Hub) sweepStaleVoiceStates() { // Channel-conditional delete: only removes the row if it still points // at the channel we snapshotted. If the user rejoined or moved between // the snapshot and now, the delete is a no-op and we skip the broadcast. - deleted, err := h.db.LeaveVoiceChannelIfMatch(s.userID, s.channelID, s.joinedAt) + deleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt) if err != nil { slog.Error("sweepStaleVoiceStates: LeaveVoiceChannelIfMatch failed", "err", err, "user_id", s.userID, "channel_id", s.channelID) @@ -1002,7 +1016,7 @@ func (h *Hub) sweepStaleVoiceStates() { "user_id", s.userID, "channel_id", s.channelID) h.BroadcastToAll(buildVoiceLeave(s.channelID, s.userID)) if h.livekit != nil { - _ = h.livekit.RemoveParticipant(s.channelID, s.userID, s.joinedAt) + _ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt) } } } diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index 6bbfebbb..075c5c59 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -44,7 +44,7 @@ func newTestHub(t *testing.T) (*ws.Hub, *db.DB) { // seedTestUser inserts a Member-role user and returns its ID. func seedTestUser(t *testing.T, database *db.DB, username string) int64 { t.Helper() - id, err := database.CreateUser(username, "hash", 4) + id, err := database.CreateUser(context.Background(), username, "hash", 4) if err != nil { t.Fatalf("seedUser: %v", err) } @@ -55,11 +55,11 @@ func seedTestUser(t *testing.T, database *db.DB, username string) int64 { // Owner role (id=1) has all permissions (0x7FFFFFFF), so it passes all checks. func seedOwnerUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner + _, err := database.CreateUser(context.Background(), username, "hash", 1) // roleID=1 → Owner if err != nil { t.Fatalf("seedOwnerUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedOwnerUser GetUserByUsername: %v", err) } @@ -69,7 +69,7 @@ func seedOwnerUser(t *testing.T, database *db.DB, username string) *db.User { // seedTestChannel inserts a channel and returns its ID. func seedTestChannel(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "text", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "text", "", "", 0) if err != nil { t.Fatalf("seedChannel: %v", err) } @@ -716,27 +716,27 @@ func TestHub_SweepRevokedSessions_KicksRevokedClient(t *testing.T) { defer hub.Stop() // Create two users with sessions. - uid1, err := database.CreateUser("alice-revoke", "hash", 3) + uid1, err := database.CreateUser(context.Background(), "alice-revoke", "hash", 3) if err != nil { t.Fatalf("CreateUser: %v", err) } - uid2, err := database.CreateUser("bob-valid", "hash", 3) + uid2, err := database.CreateUser(context.Background(), "bob-valid", "hash", 3) if err != nil { t.Fatalf("CreateUser: %v", err) } - u1, _ := database.GetUserByID(uid1) - u2, _ := database.GetUserByID(uid2) + u1, _ := database.GetUserByID(context.Background(), uid1) + u2, _ := database.GetUserByID(context.Background(), uid2) token1 := "revoke-token-1" token2 := "valid-token-2" hash1 := auth.HashToken(token1) hash2 := auth.HashToken(token2) - if _, err := database.CreateSession(uid1, hash1, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid1, hash1, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession 1: %v", err) } - if _, err := database.CreateSession(uid2, hash2, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), uid2, hash2, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession 2: %v", err) } @@ -750,7 +750,7 @@ func TestHub_SweepRevokedSessions_KicksRevokedClient(t *testing.T) { time.Sleep(20 * time.Millisecond) // Delete alice's session (simulating logout from another device). - if err := database.DeleteSession(hash1); err != nil { + if err := database.DeleteSession(context.Background(), hash1); err != nil { t.Fatalf("DeleteSession: %v", err) } @@ -909,14 +909,14 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { defer hub.Stop() chID := seedTestChannel(t, database, "secret-room") - ch, err := database.GetChannel(chID) + ch, err := database.GetChannel(context.Background(), chID) if err != nil || ch == nil { t.Fatalf("GetChannel: %v", err) } owner := seedOwnerUser(t, database, "vis-owner") memberID := seedTestUser(t, database, "vis-member") - member, err := database.GetUserByID(memberID) + member, err := database.GetUserByID(context.Background(), memberID) if err != nil || member == nil { t.Fatalf("GetUserByID: %v", err) } @@ -930,7 +930,7 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { time.Sleep(30 * time.Millisecond) // Hide the channel from the Member role (deny ReadMessages). - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, chID, ); err != nil { @@ -944,7 +944,7 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { drainForMsgType(t, ownerSend, "channel_create") // Restore visibility — the member gets the channel back. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = 4`, chID, ); err != nil { t.Fatalf("delete override: %v", err) @@ -958,7 +958,7 @@ func TestRefreshChannelVisibility_ForcesFullResyncForStaleResumes(t *testing.T) hub, database := newTestHub(t) chID := seedTestChannel(t, database, "watermark-room") - ch, err := database.GetChannel(chID) + ch, err := database.GetChannel(context.Background(), chID) if err != nil || ch == nil { t.Fatalf("GetChannel: %v", err) } diff --git a/Server/ws/livekit.go b/Server/ws/livekit.go index aacafbb8..a1392e00 100644 --- a/Server/ws/livekit.go +++ b/Server/ws/livekit.go @@ -153,11 +153,11 @@ func (c *LiveKitClient) URL() string { const lkTimeout = 5 * time.Second // RemoveParticipant forcefully disconnects a participant from a room. -func (c *LiveKitClient) RemoveParticipant(channelID int64, userID int64, voiceJoinToken string) error { +func (c *LiveKitClient) RemoveParticipant(ctx context.Context, channelID int64, userID int64, voiceJoinToken string) error { roomName := RoomName(channelID) identity := participantIdentity(userID, voiceJoinToken) - ctx, cancel := context.WithTimeout(context.Background(), lkTimeout) + ctx, cancel := context.WithTimeout(ctx, lkTimeout) defer cancel() _, err := c.roomSvc.RemoveParticipant(ctx, &livekit.RoomParticipantIdentity{ Room: roomName, diff --git a/Server/ws/livekit_test.go b/Server/ws/livekit_test.go index ebb1b5b6..7debe3c1 100644 --- a/Server/ws/livekit_test.go +++ b/Server/ws/livekit_test.go @@ -391,10 +391,10 @@ func TestWebhook_ParticipantLeft_NoDoubleBroadcast_AfterFreshCleanup(t *testing. // Insert the matching DB row first so the simulated client carries the // same join token production would have persisted and handed to LiveKit. - if err := database.JoinVoiceChannel(user.ID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - vs, err := database.GetVoiceState(user.ID) + vs, err := database.GetVoiceState(context.Background(), user.ID) if err != nil || vs == nil { t.Fatalf("GetVoiceState: %v (nil=%v)", err, vs == nil) } @@ -407,7 +407,7 @@ func TestWebhook_ParticipantLeft_NoDoubleBroadcast_AfterFreshCleanup(t *testing. // --- Simulate what serve.go fresh-cleanup does (lines 150-172) --- // 1. Delete the DB row. - deleted, err := database.LeaveVoiceChannelIfMatch(user.ID, chanID, vs.JoinedAt) + deleted, err := database.LeaveVoiceChannelIfMatch(context.Background(), user.ID, chanID, vs.JoinedAt) if err != nil || !deleted { t.Fatalf("LeaveVoiceChannelIfMatch: err=%v deleted=%v", err, deleted) } @@ -459,18 +459,18 @@ func TestWebhook_ParticipantLeft_OldToken_DoesNotTeardownReplacement(t *testing. // Create an old same-channel voice session, then rejoin the same channel so // the DB carries a replacement join token like production would. - if err := database.JoinVoiceChannel(user.ID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil { t.Fatalf("JoinVoiceChannel(old): %v", err) } - oldState, err := database.GetVoiceState(user.ID) + oldState, err := database.GetVoiceState(context.Background(), user.ID) if err != nil || oldState == nil { t.Fatalf("GetVoiceState(old): %v (nil=%v)", err, oldState == nil) } - if err := database.JoinVoiceChannel(user.ID, chanID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user.ID, chanID); err != nil { t.Fatalf("JoinVoiceChannel(new): %v", err) } - newState, err := database.GetVoiceState(user.ID) + newState, err := database.GetVoiceState(context.Background(), user.ID) if err != nil || newState == nil { t.Fatalf("GetVoiceState(new): %v (nil=%v)", err, newState == nil) } @@ -504,7 +504,7 @@ func TestWebhook_ParticipantLeft_OldToken_DoesNotTeardownReplacement(t *testing. } // DB row should still exist. - vs, err := database.GetVoiceState(user.ID) + vs, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index 51898cf9..12baad35 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -98,7 +98,7 @@ func parseRoomChannelID(roomName string) (int64, error) { return strconv.ParseInt(roomName[8:], 10, 64) } -func (h *Hub) handleWebhookParticipantJoined(_ context.Context, event *livekit.WebhookEvent) { +func (h *Hub) handleWebhookParticipantJoined(ctx context.Context, event *livekit.WebhookEvent) { p := event.GetParticipant() room := event.GetRoom() if p == nil || room == nil { @@ -128,12 +128,12 @@ func (h *Hub) handleWebhookParticipantJoined(_ context.Context, event *livekit.W // A replayed token from a previous session will not have a matching row, // so we remove the rogue participant from LiveKit. if h.db != nil { - state, stateErr := h.db.GetVoiceState(userID) + state, stateErr := h.db.GetVoiceState(ctx, userID) if stateErr != nil || state == nil || state.ChannelID != channelID { slog.Warn("livekit webhook: rogue participant_joined — no matching voice state, removing", "user_id", userID, "channel_id", channelID) if h.livekit != nil { - if rmErr := h.livekit.RemoveParticipant(channelID, userID, joinToken); rmErr != nil { //nolint:contextcheck // RemoveParticipant manages its own timeout context + if rmErr := h.livekit.RemoveParticipant(ctx, channelID, userID, joinToken); rmErr != nil { slog.Error("livekit webhook: failed to remove rogue participant", "error", rmErr, "user_id", userID, "channel_id", channelID) } @@ -146,7 +146,7 @@ func (h *Hub) handleWebhookParticipantJoined(_ context.Context, event *livekit.W "user_id", userID, "channel_id", channelID, "expected_token", state.JoinedAt, "got_token", joinToken) if h.livekit != nil { - if rmErr := h.livekit.RemoveParticipant(channelID, userID, joinToken); rmErr != nil { //nolint:contextcheck // RemoveParticipant manages its own timeout context + if rmErr := h.livekit.RemoveParticipant(ctx, channelID, userID, joinToken); rmErr != nil { slog.Error("livekit webhook: failed to remove stale participant", "error", rmErr, "user_id", userID, "channel_id", channelID) } @@ -210,7 +210,7 @@ func (h *Hub) handleWebhookParticipantLeft(ctx context.Context, event *livekit.W } else if h.db != nil { // Client has voiceChID=0 or moved to a different channel (e.g. // after F5 reload), or this webhook is for an older join instance. - deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken) + deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(ctx, userID, channelID, joinToken) if dbErr != nil { slog.Error("livekit webhook: LeaveVoiceChannelIfMatch failed (stale DB row)", "error", dbErr, "user_id", userID, "channel_id", channelID) @@ -223,7 +223,7 @@ func (h *Hub) handleWebhookParticipantLeft(ctx context.Context, event *livekit.W } else if h.db != nil { // Client already disconnected from WS — use channel-conditional delete // to avoid wiping a newer row if the user reconnected and rejoined. - deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken) + deleted, dbErr := h.db.LeaveVoiceChannelIfMatch(ctx, userID, channelID, joinToken) if dbErr != nil { slog.Error("livekit webhook: LeaveVoiceChannelIfMatch failed (client gone)", "error", dbErr, "user_id", userID, "channel_id", channelID) diff --git a/Server/ws/reconnect_db_test.go b/Server/ws/reconnect_db_test.go index 3d41e5ac..c948c09d 100644 --- a/Server/ws/reconnect_db_test.go +++ b/Server/ws/reconnect_db_test.go @@ -61,7 +61,7 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) { // computeAllowedChannels returns an empty channel set — but events with // channelID=0 (global) bypass the per-channel filter in the DB event store // and in EventsSinceFiltered, so they are always returned. - userID, err := database.CreateUser("reconnect-db-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "reconnect-db-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -69,7 +69,7 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) { if err != nil { t.Fatalf("GenerateToken: %v", err) } - if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } diff --git a/Server/ws/serve.go b/Server/ws/serve.go index c245dada..9e2336e4 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -97,13 +97,13 @@ func (h *Hub) upgradeAndAuth( // Look up role name for protocol-compliant payloads and cache on client. roleName := "member" - if role, roleErr := database.GetRoleByID(user.RoleID); roleErr == nil && role != nil { + if role, roleErr := database.GetRoleByID(r.Context(), user.RoleID); roleErr == nil && role != nil { roleName = strings.ToLower(role.Name) } c.roleName = roleName slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr) - db.WriteAudit(database, user.ID, "ws_connect", "user", user.ID, + db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "ws_connect", "user", user.ID, "WebSocket connected from "+r.RemoteAddr) return c, lastSeq, nil @@ -124,7 +124,7 @@ func (h *Hub) handleReconnect( } // Compute the set of channel IDs the reconnecting user can access so that // channel-scoped replay events are filtered by current permissions (M3). - allowedChannelIDs, err := h.computeAllowedChannels(database, c.user) + allowedChannelIDs, err := h.computeAllowedChannels(ctx, database, c.user) if err != nil { slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready", "user_id", c.userID, "err", err) @@ -179,7 +179,7 @@ func (h *Hub) handleReconnect( // is included in the payload so the client can attribute reconnect // behaviour without separate metric scraping. slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource) - if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, replaySource)); err != nil { + if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil { slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err) h.unregisterNow(c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") @@ -196,7 +196,7 @@ func (h *Hub) handleReconnect( slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource) // Update presence but skip member_join — user was already known. - if updateErr := database.UpdateUserStatus(c.userID, "online"); updateErr != nil { + if updateErr := database.UpdateUserStatus(ctx, c.userID, "online"); updateErr != nil { slog.Warn("ws UpdateUserStatus", "err", updateErr) } h.BroadcastToAll(buildPresenceMsg(c.userID, "online")) @@ -210,13 +210,13 @@ func (h *Hub) handleReconnect( // permissions.Checker predicate shared with buildReady and REST // ListVisibleChannels, so replay-buffer filtering can never drift from the // ready payload's visible channels. -func (h *Hub) computeAllowedChannels(database *db.DB, user *db.User) (map[int64]bool, error) { - channels, err := database.ListChannels() +func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user *db.User) (map[int64]bool, error) { + channels, err := database.ListChannels(ctx) if err != nil { return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err) } - role, err := database.GetRoleByID(user.RoleID) + role, err := database.GetRoleByID(ctx, user.RoleID) if err != nil { return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err) } @@ -226,7 +226,7 @@ func (h *Hub) computeAllowedChannels(database *db.DB, user *db.User) (map[int64] if role != nil { var overrides map[int64]db.ChannelOverride if !permissions.HasAdmin(role.Permissions) { - overrides, err = database.GetAllChannelPermissionsForRole(role.ID) + overrides, err = database.GetAllChannelPermissionsForRole(ctx, role.ID) if err != nil { return nil, fmt.Errorf("computeAllowedChannels GetAllChannelPermissionsForRole: %w", err) } @@ -235,7 +235,7 @@ func (h *Hub) computeAllowedChannels(database *db.DB, user *db.User) (map[int64] } // Include the user's open DM channels. - dmChannels, dmErr := database.GetUserDMChannels(user.ID) + dmChannels, dmErr := database.GetUserDMChannels(ctx, user.ID) if dmErr != nil { slog.Warn("computeAllowedChannels GetUserDMChannels", "err", dmErr) // Non-fatal: DM events will simply be filtered out. @@ -255,10 +255,10 @@ func (h *Hub) handleFreshConnect( // When a user F5-reloads while in voice, the DB row from the previous // session must be removed so the ready payload doesn't include it and // other clients see a voice_leave broadcast. - if vs, err := database.GetVoiceState(c.userID); err == nil && vs != nil { + if vs, err := database.GetVoiceState(ctx, c.userID); err == nil && vs != nil { slog.Info("ws fresh connect: cleaning stale voice state", "user_id", c.userID, "channel_id", vs.ChannelID) - if _, delErr := database.LeaveVoiceChannelIfMatch(c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil { + if _, delErr := database.LeaveVoiceChannelIfMatch(ctx, c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil { slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr) } h.BroadcastToAll(buildVoiceLeave(vs.ChannelID, c.userID)) @@ -266,16 +266,18 @@ func (h *Hub) handleFreshConnect( // BUG-089: Capture stale join token so the goroutine only removes // the exact stale participant. The identity includes joinedAt, so // even if the user rejoins voice quickly, the new session has a - // different identity and won't be removed. Use a hub-stop-aware - // context to avoid goroutine leaks on shutdown. + // different identity and won't be removed. The removal must + // complete even if this connection drops mid-handshake, so detach + // from cancellation (values kept); shutdown is handled via h.stop. staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt - go func() { //nolint:contextcheck // goroutine intentionally detaches from request context; lifecycle managed via h.stop + lkCtx := context.WithoutCancel(ctx) + go func() { select { case <-h.stop: return default: } - if err := h.livekit.RemoveParticipant(staleChID, staleUserID, staleJoinToken); err != nil { + if err := h.livekit.RemoveParticipant(lkCtx, staleChID, staleUserID, staleJoinToken); err != nil { slog.Warn("ws fresh connect: RemoveParticipant failed (may already be gone)", "err", err, "user_id", staleUserID, "channel_id", staleChID) } @@ -286,7 +288,7 @@ func (h *Hub) handleFreshConnect( // Look up role for permission-filtered ready payload. // Fail closed: if the role lookup fails, disconnect rather than serving // a permissive ready payload with nil role (BUG-094). - userRole, roleErr := database.GetRoleByID(c.user.RoleID) + userRole, roleErr := database.GetRoleByID(ctx, c.user.RoleID) if roleErr != nil || userRole == nil { slog.Error("ws: role lookup failed, disconnecting", "user_id", c.userID, "role_id", c.user.RoleID, "err", roleErr) _ = conn.Close(websocket.StatusInternalError, "role lookup failed") @@ -301,13 +303,13 @@ func (h *Hub) handleFreshConnect( // Fresh connection or replay fallback: full auth_ok + ready flow. slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName) - if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(c.user, c.roleName, "none")); err != nil { + if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil { slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err) h.unregisterNow(c) _ = conn.Close(websocket.StatusInternalError, "handshake failed") return err } - if ready, readyErr := h.buildReady(database, c.userID, userRole); readyErr == nil { + if ready, readyErr := h.buildReady(ctx, database, c.userID, userRole); readyErr == nil { slog.Info("ws sending ready payload", "user_id", c.userID, "payload_bytes", len(ready)) if err := conn.Write(ctx, websocket.MessageText, ready); err != nil { slog.Warn("ws: failed to send ready payload", "user_id", c.userID, "err", err) @@ -324,7 +326,7 @@ func (h *Hub) handleFreshConnect( return readyErr } - if updateErr := database.UpdateUserStatus(c.userID, "online"); updateErr != nil { + if updateErr := database.UpdateUserStatus(ctx, c.userID, "online"); updateErr != nil { slog.Warn("ws UpdateUserStatus", "err", updateErr) } @@ -404,6 +406,10 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { var lastReadErr error defer func() { + // The connection is gone, so ctx is (or is about to be) cancelled. + // Teardown DB writes must still complete — a dead connection must not + // cancel its own cleanup — so detach cancellation but keep values. + cleanupCtx := context.WithoutCancel(ctx) // Snapshot voice state BEFORE unregister to avoid TOCTOU with replacement connections. voiceChID := c.getVoiceChID() replaced := hub.unregisterNow(c) @@ -415,7 +421,7 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { // cleaning here would delete the replacement's DB row whenever // teardown snapshots voiceChID before the transfer zeroes it. if voiceChID != 0 && !replaced { - hub.handleVoiceLeave(ctx, c) + hub.handleVoiceLeave(cleanupCtx, c) } c.mu.Lock() received := c.msgsReceived @@ -445,7 +451,7 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { slog.Info("websocket disconnected", attrs...) if !replaced { - _ = hub.db.UpdateUserStatus(c.userID, "offline") + _ = hub.db.UpdateUserStatus(cleanupCtx, c.userID, "offline") hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline")) } } @@ -494,7 +500,7 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db } hash := auth.HashToken(p.Token) - sess, err := database.GetSessionByTokenHash(hash) + sess, err := database.GetSessionByTokenHash(ctx, hash) if err != nil || sess == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token")) return nil, "", 0, fmt.Errorf("auth: invalid session") @@ -505,7 +511,7 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db return nil, "", 0, fmt.Errorf("auth: session expired") } - user, err := database.GetUserByID(sess.UserID) + user, err := database.GetUserByID(ctx, sess.UserID) if err != nil || user == nil { _ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found")) return nil, "", 0, fmt.Errorf("auth: user not found") @@ -526,13 +532,13 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db // - "none" — fresh connection or full re-sync (no resume) // - "buffer" — resume served from the in-memory ring buffer // - "db" — resume served from the persistent EventStore (Phase B Step 7) -func (h *Hub) buildAuthOK(user *db.User, roleName string, replaySource string) []byte { +func (h *Hub) buildAuthOK(ctx context.Context, user *db.User, roleName string, replaySource string) []byte { var avatarVal any if user.Avatar != nil { avatarVal = *user.Avatar } - serverName, motd := h.getCachedSettings() + serverName, motd := h.getCachedSettings(ctx) return buildJSON(map[string]any{ "type": MsgTypeAuthOK, @@ -594,17 +600,17 @@ func channelCanSend(role *db.Role, o db.ChannelOverride, chanType string) bool { // buildReady constructs the ready server→client message. // Per PROTOCOL.md, channels include unread_count and last_message_id per user, // and only protocol-specified fields (no slow_mode, archived, voice_* extras). -func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, error) { - channels, err := database.ListChannels() +func (h *Hub) buildReady(ctx context.Context, database *db.DB, userID int64, role *db.Role) ([]byte, error) { + channels, err := database.ListChannels(ctx) if err != nil { return nil, fmt.Errorf("buildReady ListChannels: %w", err) } - roles, err := database.ListRoles() + roles, err := database.ListRoles(ctx) if err != nil { return nil, fmt.Errorf("buildReady ListRoles: %w", err) } - members, err := database.ListMembers() + members, err := database.ListMembers(ctx) if err != nil { slog.Warn("buildReady ListMembers", "err", err) members = []db.MemberSummary{} @@ -618,7 +624,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, overrides := map[int64]db.ChannelOverride{} if role != nil && !permissions.HasAdmin(role.Permissions) { var oErr error - overrides, oErr = database.GetAllChannelPermissionsForRole(role.ID) + overrides, oErr = database.GetAllChannelPermissionsForRole(ctx, role.ID) if oErr != nil { return nil, fmt.Errorf("buildReady GetAllChannelPermissionsForRole: %w", oErr) } @@ -638,7 +644,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, } // Per-user unread counts. - unreadMap, err := database.GetChannelUnreadCounts(userID) + unreadMap, err := database.GetChannelUnreadCounts(ctx, userID) if err != nil { slog.Warn("buildReady GetChannelUnreadCounts", "err", err) unreadMap = map[int64]db.ChannelUnread{} @@ -673,7 +679,7 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, } // Collect voice states, filtered to only visible channels (BUG-095). - allVoiceStates, err := collectAllVoiceStates(database, channels) + allVoiceStates, err := collectAllVoiceStates(ctx, database, channels) if err != nil { // Non-fatal: send empty list rather than failing the whole ready payload. slog.Warn("buildReady collectAllVoiceStates", "err", err) @@ -691,13 +697,13 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, } // Load open DM channels for this user. - dmChannels, err := database.GetUserDMChannels(userID) + dmChannels, err := database.GetUserDMChannels(ctx, userID) if err != nil { slog.Warn("buildReady GetUserDMChannels", "err", err) dmChannels = []db.DMChannelInfo{} } - serverName, motd := h.getCachedSettings() + serverName, motd := h.getCachedSettings(ctx) return buildJSON(map[string]any{ "type": MsgTypeReady, @@ -715,6 +721,6 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte, // collectAllVoiceStates gathers voice states across all channels in a single // query, replacing the previous N+1 per-channel pattern. -func collectAllVoiceStates(database *db.DB, _ []db.Channel) ([]db.VoiceState, error) { - return database.GetAllVoiceStates() +func collectAllVoiceStates(ctx context.Context, database *db.DB, _ []db.Channel) ([]db.VoiceState, error) { + return database.GetAllVoiceStates(ctx) } diff --git a/Server/ws/serve_test.go b/Server/ws/serve_test.go index 9d64b52f..a2768f2f 100644 --- a/Server/ws/serve_test.go +++ b/Server/ws/serve_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" "testing/fstest" @@ -68,11 +69,11 @@ func newServeHub(t *testing.T) (*ws.Hub, *db.DB) { // seedServeUser inserts an Owner-role user and returns the full *db.User. func seedServeUser(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 1) + _, err := database.CreateUser(context.Background(), username, "hash", 1) if err != nil { t.Fatalf("seedServeUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedServeUser GetUserByUsername: %v", err) } @@ -82,7 +83,7 @@ func seedServeUser(t *testing.T, database *db.DB, username string) *db.User { // ownerRole fetches the Owner role (ID=1) for permission-aware buildReady calls. func ownerRole(t *testing.T, database *db.DB) *db.Role { t.Helper() - role, err := database.GetRoleByID(1) + role, err := database.GetRoleByID(context.Background(), 1) if err != nil || role == nil { t.Fatalf("ownerRole: %v", err) } @@ -253,7 +254,7 @@ func TestBuildReady_IncludesSeededChannel(t *testing.T) { role := ownerRole(t, database) // Seed a text channel. - chID, err := database.CreateChannel("general", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -293,7 +294,7 @@ func TestBuildReady_TextChannelHasUnreadCount(t *testing.T) { user := seedServeUser(t, database, "ready-user4") role := ownerRole(t, database) - _, err := database.CreateChannel("unread-chan", "text", "", "", 0) + _, err := database.CreateChannel(context.Background(), "unread-chan", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -365,7 +366,7 @@ func TestCollectAllVoiceStates_SkipsTextChannels(t *testing.T) { user := seedServeUser(t, database, "collect-text-user") // Only text channels — no voice states should be collected. - _, err := database.CreateChannel("text-only", "text", "", "", 0) + _, err := database.CreateChannel(context.Background(), "text-only", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -395,16 +396,16 @@ func TestCollectAllVoiceStates_IncludesVoiceParticipants(t *testing.T) { user2 := seedServeUser(t, database, "collect-voice-u2") requester := seedServeUser(t, database, "collect-voice-req") - chID, err := database.CreateChannel("voice-room", "voice", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } // Insert voice states for user1 and user2. - if err := database.JoinVoiceChannel(user1.ID, chID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user1.ID, chID); err != nil { t.Fatalf("JoinVoiceChannel user1: %v", err) } - if err := database.JoinVoiceChannel(user2.ID, chID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), user2.ID, chID); err != nil { t.Fatalf("JoinVoiceChannel user2: %v", err) } @@ -439,31 +440,31 @@ func TestBuildReady_VoiceStatesFilteredByVisibility(t *testing.T) { hub, database := newServeHub(t) // Create a member user (role 4, permissions=1635, includes ReadMessages). - _, err := database.CreateUser("vs-member", "hash", 4) + _, err := database.CreateUser(context.Background(), "vs-member", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } - member, err := database.GetUserByUsername("vs-member") + member, err := database.GetUserByUsername(context.Background(), "vs-member") if err != nil || member == nil { t.Fatalf("GetUserByUsername: %v", err) } - memberRole, err := database.GetRoleByID(4) + memberRole, err := database.GetRoleByID(context.Background(), 4) if err != nil || memberRole == nil { t.Fatalf("GetRoleByID: %v", err) } // Create two voice channels: one visible, one denied. - visibleCh, err := database.CreateChannel("public-voice", "voice", "", "", 0) + visibleCh, err := database.CreateChannel(context.Background(), "public-voice", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel visible: %v", err) } - hiddenCh, err := database.CreateChannel("hidden-voice", "voice", "", "", 1) + hiddenCh, err := database.CreateChannel(context.Background(), "hidden-voice", "voice", "", "", 1) if err != nil { t.Fatalf("CreateChannel hidden: %v", err) } // Deny READ_MESSAGES on the hidden channel for Member role (role 4). - _, err = database.Exec( + _, err = database.ExecContext(context.Background(), `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, hiddenCh, ) @@ -474,10 +475,10 @@ func TestBuildReady_VoiceStatesFilteredByVisibility(t *testing.T) { // Create users in both voice channels. u1 := seedServeUser(t, database, "vs-visible-user") u2 := seedServeUser(t, database, "vs-hidden-user") - if err := database.JoinVoiceChannel(u1.ID, visibleCh); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u1.ID, visibleCh); err != nil { t.Fatalf("JoinVoiceChannel visible: %v", err) } - if err := database.JoinVoiceChannel(u2.ID, hiddenCh); err != nil { + if err := database.JoinVoiceChannel(context.Background(), u2.ID, hiddenCh); err != nil { t.Fatalf("JoinVoiceChannel hidden: %v", err) } @@ -539,7 +540,7 @@ func TestGetCachedSettings_ReflectsDBValues(t *testing.T) { // Verify the default settings were loaded correctly from the seeded DB. var name string - if err := database.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err != nil { + if err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='server_name'").Scan(&name); err != nil { t.Fatalf("query server_name: %v", err) } if name != "OwnCord Server" { @@ -804,7 +805,7 @@ func TestGetCachedSettings_CacheMiss_RefreshesFromDB(t *testing.T) { hub, database := newServeHub(t) // Update the DB settings value so we can detect a refresh. - _, err := database.Exec("UPDATE settings SET value='Refreshed Server' WHERE key='server_name'") + _, err := database.ExecContext(context.Background(), "UPDATE settings SET value='Refreshed Server' WHERE key='server_name'") if err != nil { t.Fatalf("UPDATE settings: %v", err) } @@ -888,7 +889,7 @@ func TestBuildReady_NoVoiceChannels_EmptyVoiceStates(t *testing.T) { user := seedServeUser(t, database, "ready-novch") // Create only a text channel — voice_states list must still be non-nil. - _, err := database.CreateChannel("text-chan", "text", "", "", 0) + _, err := database.CreateChannel(context.Background(), "text-chan", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } diff --git a/Server/ws/voice_controls.go b/Server/ws/voice_controls.go index 9fa0ea36..bc1ada87 100644 --- a/Server/ws/voice_controls.go +++ b/Server/ws/voice_controls.go @@ -9,7 +9,7 @@ import ( ) // handleVoiceMuteV2 processes a voice_mute command. -func handleVoiceMuteV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceMuteV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) muteCmd := cmd.(VoiceMuteCmd) userID := info.UserID @@ -23,17 +23,17 @@ func handleVoiceMuteV2(_ context.Context, cmd Command, info ClientInfo, deps any return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } - if err := d.DB.UpdateVoiceMute(userID, muteCmd.Muted()); err != nil { + if err := d.DB.UpdateVoiceMute(ctx, userID, muteCmd.Muted()); err != nil { slog.Error("ws handleVoiceMuteV2 UpdateVoiceMute", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update mute state"}} } slog.Debug("voice mute changed", "user_id", userID, "muted", muteCmd.Muted(), "channel_id", info.VoiceChannelID) - return voiceStateBroadcast(d, userID) + return voiceStateBroadcast(ctx, d, userID) } // handleVoiceDeafenV2 processes a voice_deafen command. -func handleVoiceDeafenV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceDeafenV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) deafenCmd := cmd.(VoiceDeafenCmd) userID := info.UserID @@ -47,17 +47,17 @@ func handleVoiceDeafenV2(_ context.Context, cmd Command, info ClientInfo, deps a return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } - if err := d.DB.UpdateVoiceDeafen(userID, deafenCmd.Deafened()); err != nil { + if err := d.DB.UpdateVoiceDeafen(ctx, userID, deafenCmd.Deafened()); err != nil { slog.Error("ws handleVoiceDeafenV2 UpdateVoiceDeafen", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update deafen state"}} } slog.Debug("voice deafen changed", "user_id", userID, "deafened", deafenCmd.Deafened(), "channel_id", info.VoiceChannelID) - return voiceStateBroadcast(d, userID) + return voiceStateBroadcast(ctx, d, userID) } // handleVoiceCameraV2 processes a voice_camera command. -func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceCameraV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) cameraCmd := cmd.(VoiceCameraCmd) userID := info.UserID @@ -73,7 +73,7 @@ func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps a } // Permission check. - if r := requirePerm(d.DB, d.Permissions, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil { + if r := requirePerm(ctx, d.DB, d.Permissions, userID, voiceChID, permissions.UseVideo, "USE_VIDEO"); r != nil { return *r } @@ -81,9 +81,9 @@ func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps a // Enforce MaxVideo limit when enabling camera using an atomic check-and-update. if enabled { - ch, chErr := d.DB.GetChannel(voiceChID) + ch, chErr := d.DB.GetChannel(ctx, voiceChID) if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 { - ok, limitErr := d.DB.EnableCameraIfUnderLimit(userID, voiceChID, ch.VoiceMaxVideo) + ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo) if limitErr != nil { slog.Error("handleVoiceCameraV2 EnableCameraIfUnderLimit", "err", limitErr, "channel_id", voiceChID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to check video limit"}} @@ -95,24 +95,24 @@ func handleVoiceCameraV2(_ context.Context, cmd Command, info ClientInfo, deps a }} } } else { - if err := d.DB.UpdateVoiceCamera(userID, true); err != nil { + if err := d.DB.UpdateVoiceCamera(ctx, userID, true); err != nil { slog.Error("ws handleVoiceCameraV2 UpdateVoiceCamera", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update camera state"}} } } } else { - if err := d.DB.UpdateVoiceCamera(userID, false); err != nil { + if err := d.DB.UpdateVoiceCamera(ctx, userID, false); err != nil { slog.Error("ws handleVoiceCameraV2 UpdateVoiceCamera", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update camera state"}} } } slog.Debug("voice camera changed", "user_id", userID, "enabled", enabled, "channel_id", voiceChID) - return voiceStateBroadcast(d, userID) + return voiceStateBroadcast(ctx, d, userID) } // handleVoiceScreenshareV2 processes a voice_screenshare command. -func handleVoiceScreenshareV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceScreenshareV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) ssCmd := cmd.(VoiceScreenshareCmd) userID := info.UserID @@ -128,23 +128,23 @@ func handleVoiceScreenshareV2(_ context.Context, cmd Command, info ClientInfo, d } // Permission check. - if r := requirePerm(d.DB, d.Permissions, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil { + if r := requirePerm(ctx, d.DB, d.Permissions, userID, voiceChID, permissions.ShareScreen, "SHARE_SCREEN"); r != nil { return *r } - if err := d.DB.UpdateVoiceScreenshare(userID, ssCmd.Enabled()); err != nil { + if err := d.DB.UpdateVoiceScreenshare(ctx, userID, ssCmd.Enabled()); err != nil { slog.Error("ws handleVoiceScreenshareV2 UpdateVoiceScreenshare", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to update screenshare state"}} } slog.Debug("voice screenshare changed", "user_id", userID, "enabled", ssCmd.Enabled(), "channel_id", voiceChID) - return voiceStateBroadcast(d, userID) + return voiceStateBroadcast(ctx, d, userID) } // voiceStateBroadcast reads the current voice state from DB and returns a // BroadcastAll event. Shared by all voice control V2 handlers. -func voiceStateBroadcast(d VoiceDeps, userID int64) Result { - state, err := d.DB.GetVoiceState(userID) +func voiceStateBroadcast(ctx context.Context, d VoiceDeps, userID int64) Result { + state, err := d.DB.GetVoiceState(ctx, userID) if err != nil { slog.Error("ws voiceStateBroadcast GetVoiceState", "err", err, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to broadcast voice state update"}} diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go index d644d4ed..7ccc30a2 100644 --- a/Server/ws/voice_handlers_test.go +++ b/Server/ws/voice_handlers_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "testing" "testing/fstest" @@ -72,11 +73,11 @@ func newVoiceHub(t *testing.T) (*ws.Hub, *db.DB) { // seedVoiceOwner inserts an Owner-role user for permission-passing tests. func seedVoiceOwner(t *testing.T, database *db.DB, username string) *db.User { t.Helper() - _, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner + _, err := database.CreateUser(context.Background(), username, "hash", 1) // roleID=1 → Owner if err != nil { t.Fatalf("seedVoiceOwner CreateUser: %v", err) } - user, err := database.GetUserByUsername(username) + user, err := database.GetUserByUsername(context.Background(), username) if err != nil || user == nil { t.Fatalf("seedVoiceOwner GetUserByUsername: %v", err) } @@ -86,7 +87,7 @@ func seedVoiceOwner(t *testing.T, database *db.DB, username string) *db.User { // seedVoiceChan creates a voice-type channel. func seedVoiceChan(t *testing.T, database *db.DB, name string) int64 { t.Helper() - id, err := database.CreateChannel(name, "voice", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) if err != nil { t.Fatalf("seedVoiceChan: %v", err) } @@ -187,7 +188,7 @@ func TestVoice_Join_SetsStateInDB(t *testing.T) { hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) time.Sleep(30 * time.Millisecond) - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -339,7 +340,7 @@ func TestVoice_Leave_ClearsStateInDB(t *testing.T) { hub.HandleMessageForTest(c, voiceLeaveMsg()) time.Sleep(30 * time.Millisecond) - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState after leave: %v", err) } @@ -403,7 +404,7 @@ func TestVoice_Mute_UpdatesStateInDB(t *testing.T) { hub.HandleMessageForTest(c, voiceMuteMsg(true)) time.Sleep(30 * time.Millisecond) - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -467,7 +468,7 @@ func TestVoice_Deafen_UpdatesStateInDB(t *testing.T) { hub.HandleMessageForTest(c, voiceDeafenMsg(true)) time.Sleep(30 * time.Millisecond) - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -552,7 +553,7 @@ func TestVoice_Camera_UpdatesState(t *testing.T) { time.Sleep(50 * time.Millisecond) // Verify DB state. - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -684,7 +685,7 @@ func TestVoice_Screenshare_UpdatesState(t *testing.T) { time.Sleep(50 * time.Millisecond) // Verify DB state. - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState: %v", err) } @@ -854,11 +855,11 @@ func TestVoice_HandleMessage_VoiceScreenshare_Dispatched(t *testing.T) { // seedVoiceChanMaxUsers creates a voice channel with a custom voice_max_users limit. func seedVoiceChanMaxUsers(t *testing.T, database *db.DB, name string, maxUsers int) int64 { t.Helper() - id, err := database.CreateChannel(name, "voice", "", "", 0) + id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0) if err != nil { t.Fatalf("seedVoiceChanMaxUsers CreateChannel: %v", err) } - if err := database.SetChannelVoiceMaxUsers(id, maxUsers); err != nil { + if err := database.SetChannelVoiceMaxUsers(context.Background(), id, maxUsers); err != nil { t.Fatalf("seedVoiceChanMaxUsers SetChannelVoiceMaxUsers: %v", err) } return id @@ -881,7 +882,7 @@ func TestVoice_Join_ChannelFull(t *testing.T) { time.Sleep(50 * time.Millisecond) // Verify first user is in DB. - state1, err := database.GetVoiceState(user1.ID) + state1, err := database.GetVoiceState(context.Background(), user1.ID) if err != nil || state1 == nil { t.Fatalf("user1 voice state missing after join: %v", err) } @@ -919,7 +920,7 @@ func TestVoice_Join_ChannelFull(t *testing.T) { } // Second user should NOT be in DB voice state. - state2, err := database.GetVoiceState(user2.ID) + state2, err := database.GetVoiceState(context.Background(), user2.ID) if err != nil { t.Fatalf("GetVoiceState user2: %v", err) } @@ -999,7 +1000,7 @@ func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { drainChan(send) // Verify in channel A via DB. - stateA, _ := database.GetVoiceState(userA.ID) + stateA, _ := database.GetVoiceState(context.Background(), userA.ID) if stateA == nil || stateA.ChannelID != chanA { t.Fatal("user should be in channel A") } @@ -1009,7 +1010,7 @@ func TestVoice_Join_SwitchChannel_LeavesOldChannel(t *testing.T) { time.Sleep(50 * time.Millisecond) // DB state should show channel B. - stateB, _ := database.GetVoiceState(userA.ID) + stateB, _ := database.GetVoiceState(context.Background(), userA.ID) if stateB == nil || stateB.ChannelID != chanB { t.Error("user should be in channel B after switching") } @@ -1070,7 +1071,7 @@ func TestVoice_Leave_OnDisconnect(t *testing.T) { time.Sleep(30 * time.Millisecond) // DB state should be cleared. - state, err := database.GetVoiceState(user.ID) + state, err := database.GetVoiceState(context.Background(), user.ID) if err != nil { t.Fatalf("GetVoiceState after disconnect: %v", err) } diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index 473cc1c2..edbac54d 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -58,13 +58,13 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe return } - if !h.requireChannelPerm(c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { + if !h.requireChannelPerm(ctx, c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { return } // Validate the target channel exists before any state changes (leaving // the current voice channel, persisting join, etc.). - ch, err := h.db.GetChannel(channelID) + ch, err := h.db.GetChannel(ctx, channelID) if err != nil || ch == nil { c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) return @@ -111,7 +111,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // background), the old row persists and JoinVoiceChannelIfCapacity's // COUNT(*) may produce an incorrect result. Fail the switch so the // user can retry cleanly. - vs, err := h.db.GetVoiceState(c.userID) + vs, err := h.db.GetVoiceState(ctx, c.userID) if err != nil { slog.Warn("handleVoiceJoin: could not verify voice state cleared", "user_id", c.userID, "err", err) @@ -131,7 +131,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // Check channel capacity and persist to DB atomically. maxUsers := ch.VoiceMaxUsers if maxUsers > 0 { - if err := h.db.JoinVoiceChannelIfCapacity(c.userID, channelID, maxUsers); err != nil { + if err := h.db.JoinVoiceChannelIfCapacity(ctx, c.userID, channelID, maxUsers); err != nil { if errors.Is(err, db.ErrChannelFull) { c.sendMsg(buildErrorMsg(ErrCodeChannelFull, "voice channel is full")) return @@ -142,7 +142,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe } } else { // No capacity limit — use standard join. - if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil { + if err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); err != nil { slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID) c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel")) return @@ -151,10 +151,10 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // Load the persisted row immediately so later cleanup can target this exact // join instance even if the user rejoins the same channel. - state, err := h.db.GetVoiceState(c.userID) + state, err := h.db.GetVoiceState(ctx, c.userID) if err != nil || state == nil { slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID) - h.rollbackVoiceJoin(c, channelID, false) + h.rollbackVoiceJoin(ctx, c, channelID, false) c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel")) return } @@ -167,14 +167,14 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe if h.livekit != nil { // Derive publish permissions from role — prevents SFU-level bypass // when client connects directly via direct_url (BUG-128). - canPublish := h.hasChannelPerm(c, channelID, permissions.SpeakVoice) + canPublish := h.hasChannelPerm(ctx, c, channelID, permissions.SpeakVoice) canSubscribe := true - canVideo := h.hasChannelPerm(c, channelID, permissions.UseVideo) - canScreenShare := h.hasChannelPerm(c, channelID, permissions.ShareScreen) + canVideo := h.hasChannelPerm(ctx, c, channelID, permissions.UseVideo) + canScreenShare := h.hasChannelPerm(ctx, c, channelID, permissions.ShareScreen) token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, state.JoinedAt, canPublish, canSubscribe, canVideo, canScreenShare) if tokenErr != nil { slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID) - h.rollbackVoiceJoin(c, channelID, false) + h.rollbackVoiceJoin(ctx, c, channelID, false) c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to generate voice token")) return } @@ -202,7 +202,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe h.BroadcastToAll(buildVoiceState(*state)) // Send existing channel voice states to the joiner. - existing, err := h.db.GetChannelVoiceStates(channelID) + existing, err := h.db.GetChannelVoiceStates(ctx, channelID) if err != nil { slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) return @@ -251,7 +251,7 @@ func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMe // handleVoiceTokenRefreshV2 is the V2 (pure) handler for voice_token_refresh. // It generates a fresh LiveKit token for a client already in a voice channel. -func handleVoiceTokenRefreshV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { +func handleVoiceTokenRefreshV2(ctx context.Context, cmd Command, info ClientInfo, deps any) Result { d := deps.(VoiceDeps) userID := info.UserID channelID := info.VoiceChannelID @@ -269,15 +269,15 @@ func handleVoiceTokenRefreshV2(_ context.Context, cmd Command, info ClientInfo, return Result{Error: ClientError{Code: ErrCodeInternal, Message: "voice not configured"}} } - canPublish := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.SpeakVoice) + canPublish := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.SpeakVoice) canSubscribe := true - canVideo := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.UseVideo) - canScreenShare := hasPerm(d.DB, d.Permissions, userID, channelID, permissions.ShareScreen) + canVideo := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.UseVideo) + canScreenShare := hasPerm(ctx, d.DB, d.Permissions, userID, channelID, permissions.ShareScreen) joinToken := info.VoiceJoinToken var result Result if joinToken == "" { - state, stateErr := d.DB.GetVoiceState(userID) + state, stateErr := d.DB.GetVoiceState(ctx, userID) if stateErr != nil || state == nil { slog.Error("ws handleVoiceTokenRefreshV2 GetVoiceState", "err", stateErr, "user_id", userID) return Result{Error: ClientError{Code: ErrCodeInternal, Message: "failed to refresh voice token"}} @@ -305,9 +305,11 @@ func handleVoiceTokenRefreshV2(_ context.Context, cmd Command, info ClientInfo, // rollbackVoiceJoin undoes a partially-completed voice join: clears the // client's voice channel ID, removes the DB voice state row, and broadcasts // voice_leave so other clients don't see a ghost participant. -func (h *Hub) rollbackVoiceJoin(c *Client, channelID int64, broadcast bool) { +func (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, broadcast bool) { c.clearVoiceChID() - if err := h.db.LeaveVoiceChannel(c.userID); err != nil { + // The compensating delete must run even when the join failed BECAUSE the + // connection died — that cancellation is the most common rollback trigger. + if err := h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), c.userID); err != nil { slog.Error("ws rollbackVoiceJoin LeaveVoiceChannel", "err", err, "user_id", c.userID, "channel_id", channelID) } diff --git a/Server/ws/voice_leave.go b/Server/ws/voice_leave.go index fa7a9160..481b0a76 100644 --- a/Server/ws/voice_leave.go +++ b/Server/ws/voice_leave.go @@ -46,7 +46,7 @@ func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) { // Remove from LiveKit (best-effort). if h.livekit != nil { - if err := h.livekit.RemoveParticipant(oldChID, c.userID, oldJoinToken); err != nil { //nolint:contextcheck // TODO: propagate context through this call path + if err := h.livekit.RemoveParticipant(ctx, oldChID, c.userID, oldJoinToken); err != nil { slog.Warn("handleVoiceLeave RemoveParticipant failed (may already be gone)", "err", err, "user_id", c.userID, "channel_id", oldChID) } @@ -73,22 +73,23 @@ func leaveVoiceChannelWithRetry(ctx context.Context, h *Hub, userID int64, chann } // Synchronous first attempt — channel-conditional delete. - if _, err := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken); err != nil { + if _, err := h.db.LeaveVoiceChannelIfMatch(ctx, userID, channelID, joinToken); err != nil { slog.Warn("LeaveVoiceChannelIfMatch failed, retrying in background", "err", err, "user_id", userID, "channel_id", channelID, "attempt", 1, "max_retries", 3) - // Background retries — cancellable via ctx or hub stop. + // Background retries — cancellable via hub stop only. The caller's ctx + // is detached: on the webhook path it dies the moment the handler + // returns, and on the voice_leave path it dies with the connection — + // either would kill retry 2 before it ever ran, leaving a ghost + // voice_states row holding a capacity slot until the 60s sweep. go func() { + retryCtx := context.WithoutCancel(ctx) const maxRetries = 3 delay := 200 * time.Millisecond for attempt := 2; attempt <= maxRetries; attempt++ { select { - case <-ctx.Done(): - slog.Info("LeaveVoiceChannelIfMatch retry cancelled (context)", - "user_id", userID, "channel_id", channelID, "attempt", attempt) - return case <-h.stop: slog.Info("LeaveVoiceChannelIfMatch retry cancelled (hub stop)", "user_id", userID, "channel_id", channelID, "attempt", attempt) @@ -97,7 +98,7 @@ func leaveVoiceChannelWithRetry(ctx context.Context, h *Hub, userID int64, chann } delay *= 2 - if _, retryErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken); retryErr != nil { + if _, retryErr := h.db.LeaveVoiceChannelIfMatch(retryCtx, userID, channelID, joinToken); retryErr != nil { slog.Warn("LeaveVoiceChannelIfMatch retry failed", "err", retryErr, "user_id", userID, "channel_id", channelID, "attempt", attempt, "max_retries", maxRetries) diff --git a/Server/ws/voice_perm_stale_test.go b/Server/ws/voice_perm_stale_test.go index 3114b517..3edbea93 100644 --- a/Server/ws/voice_perm_stale_test.go +++ b/Server/ws/voice_perm_stale_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "testing" "github.com/owncord/server/permissions" @@ -26,14 +27,14 @@ func TestHasChannelPerm_UsesLiveRoleNotConnectSnapshot(t *testing.T) { // Admin reassigns the user to a role WITHOUT CONNECT_VOICE. The live WS // connection is not refreshed, so c.user.RoleID is now stale. - if _, err := database.Exec( + if _, err := database.ExecContext(context.Background(), `INSERT INTO roles (id, name, color, permissions, position, is_default) VALUES (100, 'novoice', NULL, ?, 5, 0)`, permissions.ReadMessages, ); err != nil { t.Fatalf("seed novoice role: %v", err) } - if _, err := database.Exec(`UPDATE users SET role_id = 100 WHERE id = ?`, user.ID); err != nil { + if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 100 WHERE id = ?`, user.ID); err != nil { t.Fatalf("reassign user role: %v", err) } diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go index 4a768abc..456c6761 100644 --- a/Server/ws/ws_integration_test.go +++ b/Server/ws/ws_integration_test.go @@ -289,7 +289,7 @@ func TestServeWS_ValidAuth_FullHandshake(t *testing.T) { defer hub.Stop() // Seed user and session. - userID, err := database.CreateUser("ws-handshake-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-handshake-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -298,7 +298,7 @@ func TestServeWS_ValidAuth_FullHandshake(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -372,13 +372,13 @@ func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) { go hub.Run() defer hub.Stop() - userID, err := database.CreateUser("abruptclose", "hash", 4) + userID, err := database.CreateUser(context.Background(), "abruptclose", "hash", 4) if err != nil { t.Fatalf("CreateUser: %v", err) } token := "abrupt-close-token" tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -415,7 +415,7 @@ func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) { deadline := time.Now().Add(2 * time.Second) cleanedUp := false for time.Now().Before(deadline) { - user, getErr := database.GetUserByID(userID) + user, getErr := database.GetUserByID(context.Background(), userID) if getErr != nil { t.Fatalf("GetUserByID: %v", getErr) } @@ -427,7 +427,7 @@ func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) { } if !cleanedUp { - user, getErr := database.GetUserByID(userID) + user, getErr := database.GetUserByID(context.Background(), userID) if getErr != nil { t.Fatalf("GetUserByID final: %v", getErr) } @@ -445,7 +445,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { go hub.Run() defer hub.Stop() - userID, err := database.CreateUser("ws-reconnect-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-reconnect-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -454,7 +454,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -500,7 +500,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - user, getErr := database.GetUserByID(userID) + user, getErr := database.GetUserByID(context.Background(), userID) if getErr != nil { t.Fatalf("GetUserByID: %v", getErr) } @@ -510,7 +510,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) { time.Sleep(20 * time.Millisecond) } - user, getErr := database.GetUserByID(userID) + user, getErr := database.GetUserByID(context.Background(), userID) if getErr != nil { t.Fatalf("GetUserByID final: %v", getErr) } @@ -527,7 +527,7 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { go hub.Run() defer hub.Stop() - userID, err := database.CreateUser("ws-voice-reconnect", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-voice-reconnect", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -536,12 +536,12 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Create a voice channel. - chID, err := database.CreateChannel("voice-reconnect", "voice", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "voice-reconnect", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -603,10 +603,10 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { // Simulate voice join AFTER conn1 is established — both in-memory and DB. // (Setting it before conn1 would cause serve.go's fresh-connect cleanup // to delete the DB row during conn1's handshake.) - if err := database.JoinVoiceChannel(userID, chID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - vsBeforeReconnect, err := database.GetVoiceState(userID) + vsBeforeReconnect, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(before reconnect): %v", err) } @@ -641,7 +641,7 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) { } // Assert: DB row still intact - vs, vsErr := database.GetVoiceState(userID) + vs, vsErr := database.GetVoiceState(context.Background(), userID) if vsErr != nil { t.Fatalf("GetVoiceState: %v", vsErr) } @@ -685,7 +685,7 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { defer hub.Stop() // Create two users: the voice user who F5-reloads, and an observer. - userID, err := database.CreateUser("ws-voice-f5", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-voice-f5", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -694,11 +694,11 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } - observerID, err := database.CreateUser("ws-observer", "hash", 1) + observerID, err := database.CreateUser(context.Background(), "ws-observer", "hash", 1) if err != nil { t.Fatalf("CreateUser (observer): %v", err) } @@ -707,12 +707,12 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { t.Fatalf("GenerateToken (observer): %v", err) } obsTokenHash := auth.HashToken(obsToken) - if _, err := database.CreateSession(observerID, obsTokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), observerID, obsTokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession (observer): %v", err) } // Create a voice channel for the user to be "in". - chID, err := database.CreateChannel("voice-test", "voice", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "voice-test", "voice", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -810,10 +810,10 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { } // Simulate voice join — both in-memory and DB - if err := database.JoinVoiceChannel(userID, chID); err != nil { + if err := database.JoinVoiceChannel(context.Background(), userID, chID); err != nil { t.Fatalf("JoinVoiceChannel: %v", err) } - vsBeforeReload, err := database.GetVoiceState(userID) + vsBeforeReload, err := database.GetVoiceState(context.Background(), userID) if err != nil { t.Fatalf("GetVoiceState(before reload): %v", err) } @@ -846,7 +846,7 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) { } // Assert 2: DB voice row is gone - vs, vsErr := database.GetVoiceState(userID) + vs, vsErr := database.GetVoiceState(context.Background(), userID) if vsErr != nil { t.Fatalf("GetVoiceState: %v", vsErr) } @@ -908,7 +908,7 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) { defer hub.Stop() // Seed user and session. - userID, err := database.CreateUser("ws-pump-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-pump-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -917,7 +917,7 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -1004,7 +1004,7 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { defer hub.Stop() // Seed two users with sessions. - userIDA, err := database.CreateUser("roundtrip-a", "hash", 1) + userIDA, err := database.CreateUser(context.Background(), "roundtrip-a", "hash", 1) if err != nil { t.Fatalf("CreateUser A: %v", err) } @@ -1012,11 +1012,11 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { if err != nil { t.Fatalf("GenerateToken A: %v", err) } - if _, err := database.CreateSession(userIDA, auth.HashToken(tokenA), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userIDA, auth.HashToken(tokenA), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession A: %v", err) } - userIDB, err := database.CreateUser("roundtrip-b", "hash", 1) + userIDB, err := database.CreateUser(context.Background(), "roundtrip-b", "hash", 1) if err != nil { t.Fatalf("CreateUser B: %v", err) } @@ -1024,12 +1024,12 @@ func TestIntegration_MessageRoundTrip(t *testing.T) { if err != nil { t.Fatalf("GenerateToken B: %v", err) } - if _, err := database.CreateSession(userIDB, auth.HashToken(tokenB), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userIDB, auth.HashToken(tokenB), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession B: %v", err) } // Create a text channel for the chat. - chID, err := database.CreateChannel("integration-chat", "text", "", "", 0) + chID, err := database.CreateChannel(context.Background(), "integration-chat", "text", "", "", 0) if err != nil { t.Fatalf("CreateChannel: %v", err) } @@ -1149,7 +1149,7 @@ func TestIntegration_SequenceNumbers(t *testing.T) { go hub.Run() defer hub.Stop() - userID, err := database.CreateUser("seq-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "seq-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -1157,7 +1157,7 @@ func TestIntegration_SequenceNumbers(t *testing.T) { if err != nil { t.Fatalf("GenerateToken: %v", err) } - if _, err := database.CreateSession(userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } @@ -1242,7 +1242,7 @@ func TestServeWS_BannedUser_ReceivesError(t *testing.T) { defer hub.Stop() // Seed user, then ban them. - userID, err := database.CreateUser("ws-banned-user", "hash", 1) + userID, err := database.CreateUser(context.Background(), "ws-banned-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -1251,11 +1251,11 @@ func TestServeWS_BannedUser_ReceivesError(t *testing.T) { t.Fatalf("GenerateToken: %v", err) } tokenHash := auth.HashToken(token) - if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil { + if _, err := database.CreateSession(context.Background(), userID, tokenHash, "test", "127.0.0.1"); err != nil { t.Fatalf("CreateSession: %v", err) } // Ban the user permanently. - if err := database.BanUser(userID, "test ban", nil); err != nil { + if err := database.BanUser(context.Background(), userID, "test ban", nil); err != nil { t.Fatalf("BanUser: %v", err) }