fix(federation): heal path broadcasts user_updated so tombstoned-stub DMs update live (S4)
This commit is contained in:
@@ -279,6 +279,33 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
|
||||
expect(stub.isDeleted, `reason=${reason}`).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('broadcasts user_updated to the survivor of a tombstoned stub 1-on-1 DM', async () => {
|
||||
seedPeer();
|
||||
seedJournal('E0'); // deadEpoch E0 (differs from the E1 we heal with)
|
||||
seedUser('stub-1', { passwordHash: STUB }); // pure S2S stub on ORIGIN
|
||||
flag('stub-1'); // federation_heal_pending = 1
|
||||
|
||||
// Survivor (local native user) + 1-on-1 DM with the flagged stub.
|
||||
testDb.insert(schema.users).values({
|
||||
id: 'survivor', username: 'survivor', passwordHash: '$2b$10$localhash',
|
||||
homeInstance: null, homeUserId: null, isDeleted: 0, createdAt: Date.now(),
|
||||
}).run();
|
||||
testDb.insert(schema.dmChannels).values({ id: 'dm_heal', ownerId: null, createdAt: Date.now() }).run();
|
||||
testDb.insert(schema.dmMembers).values({ dmChannelId: 'dm_heal', userId: 'stub-1', closed: 0 }).run();
|
||||
testDb.insert(schema.dmMembers).values({ dmChannelId: 'dm_heal', userId: 'survivor', closed: 0 }).run();
|
||||
|
||||
const { connectionManager } = await import('../ws/handler.js');
|
||||
const sendToUser = connectionManager.sendToUser as ReturnType<typeof vi.fn>;
|
||||
sendToUser.mockClear();
|
||||
|
||||
const { healResetIncarnation } = await import('./federationReset.js');
|
||||
healResetIncarnation(ORIGIN, 'E1', 'initiate_accepted'); // genuine reset (E0 != E1) → tombstones stub-1
|
||||
|
||||
const call = sendToUser.mock.calls.find(([uid, ev]) => uid === 'survivor' && ev?.type === 'user_updated');
|
||||
expect(call).toBeTruthy();
|
||||
expect(call![1].user).toMatchObject({ id: 'stub-1', isDeleted: true, username: 'Deleted User' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('healResetIncarnation — real-account quarantine (Phase 2)', () => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { extractDomain } from '../routes/federation.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import { tombstoneUser } from './userDeletion.js';
|
||||
import { tombstoneUser, collectDeletionBroadcastTargets } from './userDeletion.js';
|
||||
import { sanitizeUser } from './sanitize.js';
|
||||
import type { PeerActivationReason } from './federationPeerActivation.js';
|
||||
|
||||
/** Pure-stub sentinel: a user replicated purely over S2S (no local credentials). */
|
||||
@@ -264,7 +265,19 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P
|
||||
// §1 invariant that a remote's reset never destroys our non-re-syncable
|
||||
// content. Each call opens its own transaction, so this loop stays UNWRAPPED.
|
||||
for (const stub of stubs) {
|
||||
// Collect co-members BEFORE tombstoning — it deletes the DM/friend/space
|
||||
// rows this set is derived from, so reading them after would return empty
|
||||
// and the survivors' clients would never learn the stub became a
|
||||
// "Deleted User". Mirrors admin.ts / users.ts / federation.ts identity-delete.
|
||||
const targets = collectDeletionBroadcastTargets(stub.id).targetUserIds;
|
||||
tombstoneUser(stub.id, { purgeContent: false });
|
||||
// Re-read the now-tombstoned row and broadcast the sanitized (anonymized)
|
||||
// profile so every surviving co-member updates live — no reload required.
|
||||
const deletedRow = db.select().from(schema.users).where(eq(schema.users.id, stub.id)).get();
|
||||
if (deletedRow) {
|
||||
const event = { type: 'user_updated' as const, user: sanitizeUser(deletedRow) };
|
||||
for (const targetId of targets) connectionManager.sendToUser(targetId, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the heal flag on exactly the stubs we healed, keyed by id.
|
||||
|
||||
Reference in New Issue
Block a user