Files
OwnCord/Server/db/account_test.go
T
J3vbandClaude 5202e3fe1e fix: correctness fixes from the 2026-08-20 bug hunt (#1398)
* fix(identity): 2 defect(s) (OC-0192, OC-0197)

OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.

OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.

* fix(ws): 1 defect(s) (OC-0196)

A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.

* fix(api): 1 defect(s) (OC-0198)

* fix(ws): 1 defect(s) (OC-0200)

normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.

* fix(api): 1 defect(s) (OC-0202)

* fix(admin): 1 defect(s) (OC-0203)

Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.

* fix(client): 1 defect(s) (OC-0205)

* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)

* fix(client): 1 defect(s) (OC-0208)

* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)

OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.

OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).

OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.

* fix(ws): 1 defect(s) (OC-0211)

* fix(identity): 1 defect(s) (OC-0214)

The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.

* fix(client): 1 defect(s) (OC-0215)

* fix(voice): 1 defect(s) (OC-0216)

* fix(client): 1 defect(s) (OC-0217)

* fix(voice): 1 defect(s) (OC-0219)

rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.

* fix(client): 2 defect(s) (OC-0220, OC-0224)

dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.

updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.

* fix(client): 1 defect(s) (OC-0221)

Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.

* fix(ws): 1 defect(s) (OC-0222)

handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.

* fix(mentions): 1 defect(s) (OC-0223)

* fix(admin): 1 defect(s) (OC-0225)

* fix(client): 1 defect(s) (OC-0226)

* fix(client): 1 defect(s) (OC-0228)

* fix(client): 1 defect(s) (OC-0230)

Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.

* fix(voice): 1 defect(s) (OC-0231)

* fix(client): 1 defect(s) (OC-0232)

Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.

* fix(client): 1 defect(s) (OC-0233)

notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).

* fix(client): 1 defect(s) (OC-0234)

* fix(client): 1 defect(s) (OC-0236)

* fix(ws): 1 defect(s) (OC-0237)

* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)

* fix(identity): 1 defect(s) (OC-0195)

Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.

* fix(dm): 1 defect(s) (OC-0199)

handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.

* fix(voice): 1 defect(s) (OC-0206)

vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.

* fix(client): 1 defect(s) (OC-0229)

* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful

The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.

The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.

* fix(dm): 1 defect(s) (OC-0194)

Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.

The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.

Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.

* fix(ws): 1 defect(s) (OC-0210)

* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed

Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.

Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.

OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.

* docs: record the dm_channel_open emission on 1:1 DM creation

POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.

The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 20:45:30 +02:00

558 lines
21 KiB
Go

package db_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/owncord/server/db"
)
// ─── DeleteAccount — last admin guard ────────────────────────────────────────
func TestDeleteAccount_LastOwnerBlocked(t *testing.T) {
database := openMigratedMemory(t)
// Create a single owner (role_id=1). No other admins exist.
ownerID := seedUser(t, database, "owner")
setRole(t, database, ownerID, 1) // Owner
err := database.DeleteAccount(context.Background(), ownerID)
if !errors.Is(err, db.ErrLastAdmin) {
t.Errorf("DeleteAccount(last owner) = %v, want ErrLastAdmin", err)
}
}
func TestDeleteAccount_LastAdminBlocked(t *testing.T) {
database := openMigratedMemory(t)
adminID := seedUser(t, database, "admin")
setRole(t, database, adminID, 2) // Admin
err := database.DeleteAccount(context.Background(), adminID)
if !errors.Is(err, db.ErrLastAdmin) {
t.Errorf("DeleteAccount(last admin) = %v, want ErrLastAdmin", err)
}
}
func TestDeleteAccount_AllowedWhenOtherAdminExists(t *testing.T) {
database := openMigratedMemory(t)
admin1 := seedUser(t, database, "admin1")
admin2 := seedUser(t, database, "admin2")
setRole(t, database, admin1, 2) // Admin
setRole(t, database, admin2, 2) // Admin
err := database.DeleteAccount(context.Background(), admin1)
if err != nil {
t.Fatalf("DeleteAccount with another admin present: %v", err)
}
}
// TestDeleteAccount_AllowedWhenOtherAdminHasLapsedTempBan locks the guard's
// "is there another usable admin left" count against the same lapsed-ban
// split anonymiseUser and notBannedClause document elsewhere: an admin whose
// temporary ban has expired (banned=1, ban_expires in the past) is fully
// functional per auth.IsEffectivelyBanned, so the raw `banned = 0` filter
// must not make the guard blind to them.
func TestDeleteAccount_AllowedWhenOtherAdminHasLapsedTempBan(t *testing.T) {
database := openMigratedMemory(t)
admin1 := seedUser(t, database, "admin1")
admin2 := seedUser(t, database, "admin2")
setRole(t, database, admin1, 2) // Admin
setRole(t, database, admin2, 2) // Admin
// admin2's temp ban has lapsed: banned stays 1 but ban_expires is in the
// past, so admin2 logs in and administers normally.
if _, err := database.ExecContext(context.Background(),
`UPDATE users SET banned = 1, ban_expires = '2020-01-01 00:00:00' WHERE id = ?`, admin2,
); err != nil {
t.Fatalf("set lapsed temp ban: %v", err)
}
err := database.DeleteAccount(context.Background(), admin1)
if err != nil {
t.Fatalf("DeleteAccount with a lapsed-temp-ban admin present: %v", err)
}
}
func TestDeleteAccount_AdminAllowedWhenOwnerExists(t *testing.T) {
database := openMigratedMemory(t)
ownerID := seedUser(t, database, "owner")
adminID := seedUser(t, database, "admin")
setRole(t, database, ownerID, 1) // Owner
setRole(t, database, adminID, 2) // Admin
// Admin can delete because owner still exists.
err := database.DeleteAccount(context.Background(), adminID)
if err != nil {
t.Fatalf("DeleteAccount(admin with owner present): %v", err)
}
}
// ─── DeleteAccount — member deletion ─────────────────────────────────────────
func TestDeleteAccount_MemberSucceeds(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "alice") // default role_id=4
err := database.DeleteAccount(context.Background(), userID)
if err != nil {
t.Fatalf("DeleteAccount(member): %v", err)
}
}
// ─── DeleteAccount — anonymisation ───────────────────────────────────────────
func TestDeleteAccount_AnonymisesUsername(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "alice")
if err := database.DeleteAccount(context.Background(), userID); err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
user, err := database.GetUserByID(context.Background(), userID)
if err != nil {
t.Fatalf("GetUserByID after delete: %v", err)
}
expected := fmt.Sprintf("[deleted-%d]", userID)
if user.Username != expected {
t.Errorf("Username = %q, want %q", user.Username, expected)
}
}
func TestDeleteAccount_ClearsPassword(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "bob")
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
user, _ := database.GetUserByID(context.Background(), userID)
if user.PasswordHash != "" {
t.Errorf("PasswordHash = %q, want empty", user.PasswordHash)
}
}
func TestDeleteAccount_ClearsAvatarAndTOTP(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "charlie")
// Set avatar and TOTP before deletion.
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(context.Background(), userID)
if user.Avatar != nil {
t.Errorf("Avatar = %v, want nil", user.Avatar)
}
if user.TOTPSecret != nil {
t.Errorf("TOTPSecret = %v, want nil", user.TOTPSecret)
}
}
func TestDeleteAccount_SetsBannedAndOffline(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "dave")
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
user, _ := database.GetUserByID(context.Background(), userID)
if !user.Banned {
t.Error("Banned should be true after deletion")
}
if user.Status != "offline" {
t.Errorf("Status = %q, want 'offline'", user.Status)
}
}
// ─── DeleteAccount — related data cleanup ────────────────────────────────────
func TestDeleteAccount_DeletesSessions(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "eve")
// Insert a session directly.
database.ExecContext(context.Background(),
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, 'tok123', datetime('now', '+1 day'))",
userID,
) //nolint:errcheck
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
var count int
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)
}
}
func TestDeleteAccount_SoftDeletesMessages(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "frank")
chID := seedChannel(t, database, "general")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "hello world", nil)
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
msg, err := database.GetMessage(context.Background(), msgID)
if err != nil {
t.Fatalf("GetMessage after delete: %v", err)
}
if !msg.Deleted {
t.Error("message should be soft-deleted")
}
if msg.Content != "" {
t.Errorf("message content = %q, want empty", msg.Content)
}
}
func TestDeleteAccount_NonexistentUser(t *testing.T) {
database := openMigratedMemory(t)
err := database.DeleteAccount(context.Background(), 999999)
if err == nil {
t.Error("DeleteAccount(nonexistent) should return error")
}
}
// TestDeleteAccount_SquattedAnonNameStillDeletes locks the erasure path against
// a targeted denial of service: users.username is UNIQUE COLLATE NOCASE, so an
// attacker who renamed themselves to the victim's "[deleted-<id>]" made the
// anonymising UPDATE fail, rolled the whole transaction back, and left the
// victim permanently unable to delete their own account. auth.ValidateUsername
// now reserves the namespace, but DeleteAccount must survive a squatted name on
// its own — including one already in the database from before the rule existed.
func TestDeleteAccount_SquattedAnonNameStillDeletes(t *testing.T) {
database := openMigratedMemory(t)
victimID := seedUser(t, database, "victim")
squatterID := seedUser(t, database, "squatter")
squatted := fmt.Sprintf("[deleted-%d]", victimID)
if _, err := database.ExecContext(context.Background(),
"UPDATE users SET username = ? WHERE id = ?", squatted, squatterID,
); err != nil {
t.Fatalf("squat username: %v", err)
}
if err := database.DeleteAccount(context.Background(), victimID); err != nil {
t.Fatalf("DeleteAccount must not be blockable by a squatted name: %v", err)
}
victim, err := database.GetUserByID(context.Background(), victimID)
if err != nil || victim == nil {
t.Fatalf("GetUserByID after delete: %v", err)
}
if strings.EqualFold(victim.Username, squatted) {
t.Fatalf("victim kept the squatted name %q", victim.Username)
}
if !strings.HasPrefix(victim.Username, fmt.Sprintf("[deleted-%d-", victimID)) {
t.Errorf("Username = %q, want a suffixed [deleted-%d-…] fallback", victim.Username, victimID)
}
// The erasure itself must still have happened.
if !victim.Banned || victim.PasswordHash != "" {
t.Errorf("account not anonymised: banned=%v password=%q", victim.Banned, victim.PasswordHash)
}
}
// ─── DeleteAccount — canonical admin criterion, tokens, DM cleanup ───────────
func TestDeleteAccount_LastAdminGuard_SurvivesRoleRename(t *testing.T) {
database := openMigratedMemory(t)
// The Owner can rename the seeded Admin role (id=2); the guard must key
// on the canonical role IDs, not the display name.
if _, err := database.ExecContext(context.Background(),
`UPDATE roles SET name = 'Staff' WHERE id = 2`); err != nil {
t.Fatalf("rename admin role: %v", err)
}
adminID := seedUser(t, database, "renamedadmin")
setRole(t, database, adminID, 2)
err := database.DeleteAccount(context.Background(), adminID)
if !errors.Is(err, db.ErrLastAdmin) {
t.Errorf("DeleteAccount(last holder of renamed admin role) = %v, want ErrLastAdmin", err)
}
}
func TestDeleteAccount_RevokesAPITokensAndPermanentBan(t *testing.T) {
database := openMigratedMemory(t)
uid := seedUser(t, database, "tokenuser")
tokenHash := "testhash-tokenuser"
if _, err := database.CreateAPIToken(context.Background(), uid, tokenHash, "ci", nil); err != nil {
t.Fatalf("CreateAPIToken: %v", err)
}
// A previously lapsed temp ban left ban_expires in the past; combined
// with banned=1 that reads as NOT banned (TestIsEffectivelyBanned_*).
if _, err := database.ExecContext(context.Background(),
`UPDATE users SET ban_expires = '2020-01-01 00:00:00' WHERE id = ?`, uid); err != nil {
t.Fatalf("set stale ban_expires: %v", err)
}
if err := database.DeleteAccount(context.Background(), uid); err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
if tok, _ := database.GetActiveAPIToken(context.Background(), tokenHash); tok != nil {
t.Error("API token still active after account deletion; want revoked")
}
var banExpires *string
if err := database.QueryRowContext(context.Background(),
`SELECT ban_expires FROM users WHERE id = ?`, uid).Scan(&banExpires); err != nil {
t.Fatalf("read ban_expires: %v", err)
}
if banExpires != nil {
t.Errorf("ban_expires = %q after deletion, want NULL (permanent ban)", *banExpires)
}
}
func TestDeleteAccount_LastGroupDMParticipant_RemovesChannel(t *testing.T) {
database := openMigratedMemory(t)
userA := seedUser(t, database, "dmlast-a")
userB := seedUser(t, database, "dmlast-b")
userC := seedUser(t, database, "dmlast-c")
ch, err := database.CreateGroupDMChannel(context.Background(), "grp", []int64{userA, userB, userC})
if err != nil {
t.Fatalf("CreateGroupDMChannel: %v", err)
}
if _, err := database.LeaveGroupDM(context.Background(), userB, ch.ID); err != nil {
t.Fatalf("LeaveGroupDM(userB): %v", err)
}
if _, err := database.LeaveGroupDM(context.Background(), userC, ch.ID); err != nil {
t.Fatalf("LeaveGroupDM(userC): %v", err)
}
// userA is now the last participant; deleting the account must apply
// LeaveGroupDM's invariant — a participant-less DM channel is an
// unreachable, undeletable row and must not survive.
if err := database.DeleteAccount(context.Background(), userA); err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
var count int
if err := database.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM channels WHERE id = ?`, ch.ID).Scan(&count); err != nil {
t.Fatalf("count channels: %v", err)
}
if count != 0 {
t.Errorf("group DM channel survived deletion of its last participant; want removed")
}
}
// TestDeleteAccount_ClearsProfileFields locks the erasure path against a
// regression that leaves user-authored free text (nickname, bio, status
// line) and the E2EE identity key sitting in the row after "deletion" —
// DeleteAccount documents that "all personal data is removed", and an admin
// unban would otherwise republish this text to every client.
func TestDeleteAccount_ClearsProfileFields(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "gina")
if _, err := database.ExecContext(context.Background(),
`UPDATE users SET display_name = ?, about = ?, custom_status = ?, identity_public_key = ? WHERE id = ?`,
"Gina G.", "just a bio", "in a meeting", "base64keymaterial", userID,
); err != nil {
t.Fatalf("seed profile fields: %v", err)
}
if err := database.DeleteAccount(context.Background(), userID); err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
var displayName, about, customStatus, identityKey *string
if err := database.QueryRowContext(context.Background(),
`SELECT display_name, about, custom_status, identity_public_key FROM users WHERE id = ?`, userID,
).Scan(&displayName, &about, &customStatus, &identityKey); err != nil {
t.Fatalf("read profile fields: %v", err)
}
if displayName != nil {
t.Errorf("display_name = %q, want NULL", *displayName)
}
if about != nil {
t.Errorf("about = %q, want NULL", *about)
}
if customStatus != nil {
t.Errorf("custom_status = %q, want NULL", *customStatus)
}
if identityKey != nil {
t.Errorf("identity_public_key = %q, want NULL", *identityKey)
}
}
// TestDeleteAccount_OneOnOneDM_ClosesForSurvivor covers the partner of a 1:1
// DM whose other side deletes their account. Only the deleting user's own
// dm_participants/dm_open_state rows are removed by the generic purge, so
// without the extra cleanup the channel survives with a recipient nobody can
// resolve — a blank-named sidebar row the survivor could still open and send
// into. DeleteAccount must instead close the DM out of the survivor's
// sidebar, same as the survivor closing it themselves.
func TestDeleteAccount_OneOnOneDM_ClosesForSurvivor(t *testing.T) {
database := openMigratedMemory(t)
alice := seedUser(t, database, "alice-dm")
bob := seedUser(t, database, "bob-dm")
ch, _, err := database.GetOrCreateDMChannel(context.Background(), alice, bob)
if err != nil {
t.Fatalf("GetOrCreateDMChannel: %v", err)
}
if err := database.DeleteAccount(context.Background(), bob); err != nil {
t.Fatalf("DeleteAccount(bob): %v", err)
}
// The channel itself and Alice's participant row must survive — Alice is
// still a real, undeleted DM participant.
var channelCount int
if err := database.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM channels WHERE id = ?`, ch.ID).Scan(&channelCount); err != nil {
t.Fatalf("count channels: %v", err)
}
if channelCount != 1 {
t.Errorf("channel count = %d, want 1 (1:1 DM channel must not be hard-deleted while a real participant remains)", channelCount)
}
// But it must be gone from Alice's open-DM list, not linger as a blank
// recipient she can still send into.
dms, err := database.GetUserDMChannels(context.Background(), alice)
if err != nil {
t.Fatalf("GetUserDMChannels(alice): %v", err)
}
for _, dm := range dms {
if dm.ChannelID == ch.ID {
t.Errorf("channel %d still in alice's open DM list after bob's account deletion", ch.ID)
}
}
}
// TestDeleteAccount_SurvivingDM_KeepsAttachmentsLinked is the other half of
// the unlink fix: the pre-delete "UPDATE attachments SET message_id = NULL"
// must fire ONLY for channels the purge actually emptied. A 1:1 DM whose
// other side is still a real participant keeps its channel row, so unlinking
// there would hand its attachments to the orphan sweep (message_id IS NULL,
// uploaded_at older than an hour) and silently destroy the survivor's files
// an hour later. The EXISTS guard on the UPDATE is what prevents that.
func TestDeleteAccount_SurvivingDM_KeepsAttachmentsLinked(t *testing.T) {
database := openMigratedMemory(t)
ctx := context.Background()
alice := seedUser(t, database, "alice-att")
bob := seedUser(t, database, "bob-att")
ch, _, err := database.GetOrCreateDMChannel(ctx, alice, bob)
if err != nil {
t.Fatalf("GetOrCreateDMChannel: %v", err)
}
// Alice's own message and attachment: they must outlive Bob's deletion.
msgID, err := database.CreateMessage(ctx, ch.ID, alice, "here you go", nil)
if err != nil {
t.Fatalf("CreateMessage: %v", err)
}
if _, err := database.ExecContext(ctx,
`INSERT INTO attachments (id, filename, stored_as, mime_type, size, uploader_id)
VALUES (?, ?, ?, ?, ?, ?)`,
"att-survivor-1", "photo.png", "stored-survivor.png", "image/png", 512, alice,
); err != nil {
t.Fatalf("seed attachment: %v", err)
}
if n, err := database.LinkAttachmentsToMessage(ctx, msgID, alice, []string{"att-survivor-1"}); err != nil || n != 1 {
t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err)
}
if err := database.DeleteAccount(ctx, bob); err != nil {
t.Fatalf("DeleteAccount(bob): %v", err)
}
att, err := database.GetAttachmentByID(ctx, "att-survivor-1")
if err != nil {
t.Fatalf("GetAttachmentByID: %v", err)
}
if att == nil {
t.Fatal("attachment row disappeared although the DM channel still has a live participant")
}
if att.MessageID == nil || *att.MessageID != msgID {
t.Errorf("attachment MessageID = %v, want %d (must stay linked; unlinking hands it to the orphan sweep)",
att.MessageID, msgID)
}
}
// TestDeleteAccount_EmptiedDMChannel_PreservesAttachmentsForReclaim covers
// the last member of a group DM deleting their account: the channel row is
// hard-deleted and, without unlinking first, the ON DELETE CASCADE from
// channels -> messages -> attachments (migrations/001) destroys the
// attachment rows too — the only handle the orphan sweep
// (DeleteOrphanedAttachments) has on the uploaded files, permanently
// stranding them on disk. DeleteAccount must unlink the attachments before
// the channel delete so the row (and the sweep's ability to reclaim the
// file) survives.
func TestDeleteAccount_EmptiedDMChannel_PreservesAttachmentsForReclaim(t *testing.T) {
database := openMigratedMemory(t)
userA := seedUser(t, database, "dmatt-a")
userB := seedUser(t, database, "dmatt-b")
userC := seedUser(t, database, "dmatt-c")
ch, err := database.CreateGroupDMChannel(context.Background(), "grp", []int64{userA, userB, userC})
if err != nil {
t.Fatalf("CreateGroupDMChannel: %v", err)
}
msgID, err := database.CreateMessage(context.Background(), ch.ID, userA, "look at this", nil)
if err != nil {
t.Fatalf("CreateMessage: %v", err)
}
if _, err := database.ExecContext(context.Background(),
`INSERT INTO attachments (id, filename, stored_as, mime_type, size, uploader_id)
VALUES (?, ?, ?, ?, ?, ?)`,
"att-dm-1", "photo.png", "stored-photo.png", "image/png", 1024, userA,
); err != nil {
t.Fatalf("seed attachment: %v", err)
}
if n, err := database.LinkAttachmentsToMessage(context.Background(), msgID, userA, []string{"att-dm-1"}); err != nil || n != 1 {
t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, err)
}
if _, err := database.LeaveGroupDM(context.Background(), userB, ch.ID); err != nil {
t.Fatalf("LeaveGroupDM(userB): %v", err)
}
if _, err := database.LeaveGroupDM(context.Background(), userC, ch.ID); err != nil {
t.Fatalf("LeaveGroupDM(userC): %v", err)
}
// userA is now the last participant; deleting the account empties and
// hard-deletes the channel.
if err := database.DeleteAccount(context.Background(), userA); err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
var channelCount int
if err := database.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM channels WHERE id = ?`, ch.ID).Scan(&channelCount); err != nil {
t.Fatalf("count channels: %v", err)
}
if channelCount != 0 {
t.Errorf("group DM channel survived deletion of its last participant; want removed")
}
att, err := database.GetAttachmentByID(context.Background(), "att-dm-1")
if err != nil {
t.Fatalf("GetAttachmentByID: %v", err)
}
if att == nil {
t.Fatal("attachment row was destroyed by the channel-delete cascade; want it unlinked and preserved for the orphan sweep")
}
if att.MessageID != nil {
t.Errorf("attachment MessageID = %v, want nil (unlinked ahead of the cascade)", *att.MessageID)
}
}
// ─── Helper ──────────────────────────────────────────────────────────────────
func setRole(t *testing.T, database *db.DB, userID, roleID int64) {
t.Helper()
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)
}
}