fix(dm): enforce read-only guard on DM reactions (WS) + harden purge/ownership tests
Finding 1: handleReactionAdd/Remove now drop reactions on a dead 1-on-1 (isDeadOneOnOne) — previously a survivor could react on a Deleted-User thread and the relay fanned out to all peers via undefined target-origins. Client Message.tsx withdraws add/toggle reaction affordances for dead DMs (existing reactions still display read-only). Finding 2: dmMembership purge test now runs with foreign_keys=ON (matches prod) and asserts dm_members/dm_messages cascade cleanup on channel purge. Finding 3: tombstone group-DM ownership transfer filters isDeleted=0 so ownership can never move to a tombstoned member; covered by a new test.
This commit is contained in:
@@ -33,12 +33,19 @@ function seedDm(id: string, ownerId: string | null) {
|
||||
function seedMember(dmChannelId: string, userId: string) {
|
||||
testDb.insert(schema.dmMembers).values({ dmChannelId, userId, closed: 0 }).run();
|
||||
}
|
||||
function seedDmMessage(id: string, dmChannelId: string, userId: string) {
|
||||
testDb.insert(schema.dmMessages).values({ id, dmChannelId, userId, content: 'x', createdAt: Date.now() }).run();
|
||||
}
|
||||
|
||||
describe('tombstoneUser — DM membership partition', () => {
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(':memory:');
|
||||
testDb = drizzle(sqlite, { schema });
|
||||
applyMigrations(sqlite);
|
||||
// Match production (db/index.ts:31): FK enforcement ON. better-sqlite3 defaults
|
||||
// this OFF, which would silently skip the dm_channels→dm_members/dm_messages
|
||||
// cascade and let orphaned rows survive a purge unnoticed.
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => sqlite.close());
|
||||
@@ -65,10 +72,32 @@ describe('tombstoneUser — DM membership partition', () => {
|
||||
seedDm('dm_live', null); seedMember('dm_live', 'victim'); seedMember('dm_live', 'survivor');
|
||||
// Both-dead thread — victim + an already-deleted partner → must be purged
|
||||
seedDm('dm_dead', null); seedMember('dm_dead', 'victim'); seedMember('dm_dead', 'alreadyDead');
|
||||
seedDmMessage('msg_dead', 'dm_dead', 'victim');
|
||||
|
||||
tombstoneUser('victim', { purgeContent: false });
|
||||
|
||||
expect(testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm_live')).get()).toBeTruthy();
|
||||
expect(testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm_dead')).get()).toBeUndefined();
|
||||
// Cascade cleanup: with FK ON, dropping the dm_channels row must also remove its
|
||||
// dm_members and dm_messages — no orphaned rows may linger.
|
||||
expect(testDb.select().from(schema.dmMembers).where(eq(schema.dmMembers.dmChannelId, 'dm_dead')).all()).toEqual([]);
|
||||
expect(testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, 'dm_dead')).all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('transfers owned group DM to a LIVE member, never a tombstoned one', async () => {
|
||||
const { tombstoneUser } = await import('./userDeletion.js');
|
||||
seedUser('owner'); seedUser('deadmate', { isDeleted: 1 }); seedUser('livemate');
|
||||
// owner owns a group DM whose other members are one dead + one live.
|
||||
// The membership rows are ordered so the dead member would be picked first
|
||||
// by an unfiltered `LIMIT 1` — proving the isDeleted=0 guard is what selects livemate.
|
||||
seedDm('dm_owned', 'owner');
|
||||
seedMember('dm_owned', 'owner');
|
||||
seedMember('dm_owned', 'deadmate');
|
||||
seedMember('dm_owned', 'livemate');
|
||||
|
||||
tombstoneUser('owner', { purgeContent: false });
|
||||
|
||||
const channel = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm_owned')).get();
|
||||
expect(channel?.ownerId).toBe('livemate');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user