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:
Jannis Braun
2026-07-02 16:45:44 +02:00
parent e4e83eb3fb
commit 1629f8fbe1
6 changed files with 105 additions and 4 deletions
@@ -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');
});
});
+8 -2
View File
@@ -170,11 +170,17 @@ export function tombstoneUser(uid: string, options?: TombstoneOptions): string[]
and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid))
).run();
// Transfer ownership of group DMs to the next remaining member
// Transfer ownership of group DMs to the next remaining member.
// Invariant: ownership must never transfer to a tombstoned member — join
// users and require isDeleted=0 so a dead incarnation can't become owner.
for (const { id: dmId } of ownedGroupDms) {
const nextMember = tx.select({ userId: schema.dmMembers.userId })
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmId))
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
.where(and(
eq(schema.dmMembers.dmChannelId, dmId),
eq(schema.users.isDeleted, 0),
))
.limit(1)
.get();
if (nextMember) {
+7 -1
View File
@@ -4,7 +4,7 @@ import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js';
import { isMember, getChannelSpaceId, isDmMember, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { isMember, getChannelSpaceId, isDmMember, isDeadOneOnOne, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser, type Embed, type Activity, type ActivityType, type ActivityTimestamps, type ActivityAssets, type ServerEvent, type DmCallUndeliverableFailure, type DmCallUndeliverableReason } from '@backspace/shared';
import type { CallRelayResult, CallFanoutFailure } from '../utils/federationOutbox.js';
@@ -1138,6 +1138,9 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string, isFed
if (isFederated) return;
const dmMsg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
if (!dmMsg || !isDmMember(dmMsg.dmChannelId, userId)) return;
// Read-only enforcement: a dead 1-on-1 thread (partner tombstoned) accepts no
// reaction mutations — the relay would fan out to all peers via undefined origins.
if (isDeadOneOnOne(dmMsg.dmChannelId, userId)) return;
const reactionId = generateSnowflake();
const now = Date.now();
@@ -1224,6 +1227,9 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string, is
if (isFederated) return;
const dmMsg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
if (!dmMsg || !isDmMember(dmMsg.dmChannelId, userId)) return;
// Read-only enforcement: a dead 1-on-1 thread (partner tombstoned) accepts no
// reaction mutations — the relay would fan out to all peers via undefined origins.
if (isDeadOneOnOne(dmMsg.dmChannelId, userId)) return;
const result = db.delete(schema.dmReactions)
.where(and(