From c5e3d36689ce638394d99887e2d79627acab3915 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 23:46:20 +0200 Subject: [PATCH] =?UTF-8?q?test(federation-identity):=20#3=20soft=20/=20#5?= =?UTF-8?q?=20full=20=E2=80=94=20DB=20cascade=20verification=20+=20setup?= =?UTF-8?q?=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds setupFullDeletionFixture (federated user joins remote space, authors 2 messages with reactions, opens 1-on-1 DM with a live observer). Tests #3 (soft mode: tombstone + dm_members cleared, messages/reactions retained) and #5 (full mode: tombstone + messages/reactions purged, surviving 1-on-1 DM channel). Also fixes seedPeer to install both the URL form (outbound lookup on sender) and the DOMAIN-claim form (inbound auth on receiver) — required because the test harness's ephemeral http://127.0.0.1 origin and DOMAIN-derived getOurOrigin() return different strings, while production has them coincide. This was latent: test #1 (leave mode) skips S2S, so #3 was the first test to actually exercise the S2S delete path and surfaced the dual-origin gap. --- .../test/federation-identity-deletion.test.ts | 184 ++++++++++++++++++ packages/server/test/helpers/seedPeer.ts | 23 +++ 2 files changed, 207 insertions(+) diff --git a/packages/server/test/federation-identity-deletion.test.ts b/packages/server/test/federation-identity-deletion.test.ts index 56c34cc9..9c81af2f 100644 --- a/packages/server/test/federation-identity-deletion.test.ts +++ b/packages/server/test/federation-identity-deletion.test.ts @@ -1,10 +1,111 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootTwoInstances, type TwoInstanceHarness } from './helpers/twoInstanceHarness.js'; import { peerInstances } from './helpers/seedPeer.js'; +import type { TestUser } from './helpers/testUsers.js'; +import { connectWs } from './helpers/wsListener.js'; let harness: TwoInstanceHarness; let sharedHmacSecret: string; +/** + * Setup for tests #3 / #5 / #16 / etc.: federated user has a remote space membership, + * authored 2 messages with reactions, and is in a 1-on-1 DM with another live user. + * Returns enough handles for tests to assert post-state. + */ +async function setupFullDeletionFixture(label: string): Promise<{ + homeUser: TestUser; + remoteUser: TestUser; + observerOnRemote: TestUser; + spaceId: string; + channelId: string; + authoredMessageIds: string[]; + dmChannelId: string; +}> { + const { registerLocal, createFederatedUser } = await import('./helpers/testUsers.js'); + const { homeUser, remoteUser } = await createFederatedUser(harness.home, harness.remote, label); + const observerOnRemote = await registerLocal(harness.remote, `${label}_obs`); + + // observerOnRemote creates a space, generates an invite, federated user joins. + const spaceRes = await fetch(`${harness.remote.origin}/api/spaces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${observerOnRemote.token}` }, + body: JSON.stringify({ name: `${label}-space` }), + }); + if (!spaceRes.ok) throw new Error(`create space failed: ${spaceRes.status} ${await spaceRes.text()}`); + const spaceData = await spaceRes.json() as { id: string }; + const spaceId = spaceData.id; + + // Invite endpoint takes no body — Fastify rejects empty body when content-type + // is application/json, so we omit Content-Type entirely here. + const inviteRes = await fetch(`${harness.remote.origin}/api/spaces/${spaceId}/invite`, { + method: 'POST', + headers: { Authorization: `Bearer ${observerOnRemote.token}` }, + }); + if (!inviteRes.ok) throw new Error(`create invite failed: ${inviteRes.status} ${await inviteRes.text()}`); + const inviteData = await inviteRes.json() as { inviteCode: string }; + + const joinRes = await fetch(`${harness.remote.origin}/api/spaces/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${remoteUser.token}` }, + body: JSON.stringify({ inviteCode: inviteData.inviteCode }), + }); + if (!joinRes.ok) throw new Error(`join space failed: ${joinRes.status} ${await joinRes.text()}`); + + // Get the space's first channel via GET /api/spaces/:id (must be a member; remote user just joined) + const spaceDetailsRes = await fetch(`${harness.remote.origin}/api/spaces/${spaceId}`, { + headers: { Authorization: `Bearer ${remoteUser.token}` }, + }); + if (!spaceDetailsRes.ok) throw new Error(`get space details failed: ${spaceDetailsRes.status} ${await spaceDetailsRes.text()}`); + const spaceDetails = await spaceDetailsRes.json() as { channels: { id: string }[] }; + if (!spaceDetails.channels?.length) throw new Error('space has no channels — production created none on space create'); + const channelId = spaceDetails.channels[0].id; + + // Federated user authors 2 messages (rate limit is 5/5s, so 2 back-to-back is fine). + const authoredMessageIds: string[] = []; + for (let i = 0; i < 2; i++) { + const msgRes = await fetch(`${harness.remote.origin}/api/channels/${channelId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${remoteUser.token}` }, + body: JSON.stringify({ content: `msg-${i}` }), + }); + if (!msgRes.ok) throw new Error(`create message failed: ${msgRes.status} ${await msgRes.text()}`); + const msg = await msgRes.json() as { id: string }; + authoredMessageIds.push(msg.id); + } + + // Reactions are WS-only — open a transient WS, react, close. + const reactWs = await connectWs(harness.remote.origin, remoteUser.token); + try { + for (const messageId of authoredMessageIds) { + reactWs.send({ type: 'reaction_add', messageId, emoji: '👍' }); + } + // Wait briefly for reactions to land before snapshotting. The handler is + // synchronous after the message arrives; 300ms covers WS frame transit + insert. + await new Promise(r => setTimeout(r, 300)); + } finally { + reactWs.close(); + } + + // 1-on-1 DM federated <-> observer + const dmRes = await fetch(`${harness.remote.origin}/api/dm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${remoteUser.token}` }, + body: JSON.stringify({ userId: observerOnRemote.id }), + }); + if (!dmRes.ok) throw new Error(`create dm failed: ${dmRes.status} ${await dmRes.text()}`); + const dmData = await dmRes.json() as { id: string }; + const dmChannelId = dmData.id; + + const dmMsgRes = await fetch(`${harness.remote.origin}/api/dm/${dmChannelId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${remoteUser.token}` }, + body: JSON.stringify({ content: 'hello' }), + }); + if (!dmMsgRes.ok) throw new Error(`create dm message failed: ${dmMsgRes.status} ${await dmMsgRes.text()}`); + + return { homeUser, remoteUser, observerOnRemote, spaceId, channelId, authoredMessageIds, dmChannelId }; +} + beforeAll(async () => { harness = await bootTwoInstances(); sharedHmacSecret = await peerInstances(harness.home, harness.remote); @@ -93,4 +194,87 @@ describe('Federation identity deletion — server suite', () => { expect(replInst.find(r => r.origin === harness.remote.origin)).toBeUndefined(); homeInspect.close(); }); + + it('#3 soft mode: tombstone shape, messages/reactions retained, dm membership cleared', async () => { + const fx = await setupFullDeletionFixture('t3'); + const { openInspector } = await import('./helpers/dbInspect.js'); + + // Precondition: the helper actually built the prerequisite state. + const pre = openInspector(harness.remote); + expect(pre.spaceMembersForUser(fx.remoteUser.id).map(r => r.spaceId)).toContain(fx.spaceId); + expect(pre.messagesAuthored(fx.remoteUser.id).length).toBe(2); + expect(pre.reactionsForUser(fx.remoteUser.id).length).toBe(2); + expect(pre.dmMembership(fx.remoteUser.id).map(r => r.dmChannelId)).toContain(fx.dmChannelId); + pre.close(); + + const res = await fetch(`${harness.home.origin}/api/users/@me/federation-identity/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.homeUser.token}` }, + body: JSON.stringify({ origins: [harness.remote.origin], mode: 'soft' }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.results[harness.remote.origin].success).toBe(true); + + const remote = openInspector(harness.remote); + const after = remote.user(fx.remoteUser.id); + expect(after).toBeTruthy(); + expect(after!.isDeleted).toBe(1); + expect(after!.username).toBe(`!deleted:${fx.remoteUser.id}`); + expect(after!.passwordHash).toMatch(/^[0-9a-f]{64}$/); + expect(after!.displayName).toBeNull(); + expect(after!.avatar).toBeNull(); + expect(after!.banner).toBeNull(); + expect(after!.bio).toBeNull(); + expect(after!.customStatus).toBeNull(); + expect(after!.accentColor).toBeNull(); + expect(after!.avatarColor).toBeNull(); + expect(after!.replicatedInstances).toBe('[]'); + expect(after!.status).toBe('offline'); + expect(after!.isAdmin).toBe(0); + + expect(remote.spaceMembersForUser(fx.remoteUser.id)).toEqual([]); + expect(remote.messagesAuthored(fx.remoteUser.id).length).toBe(2); // RETAINED + expect(remote.reactionsForUser(fx.remoteUser.id).length).toBe(2); // RETAINED + expect(remote.dmMembership(fx.remoteUser.id)).toEqual([]); // dm_members always cleared + expect(remote.dmChannelExists(fx.dmChannelId)).toBe(true); // other party still member + remote.close(); + + const home = openInspector(harness.home); + expect(home.registryRow(fx.homeUser.id, harness.remote.origin)).toBeUndefined(); + home.close(); + }); + + it('#5 full mode: tombstone + reactions/messages purged, 1-on-1 DM with live other party survives', async () => { + const fx = await setupFullDeletionFixture('t5'); + const { openInspector } = await import('./helpers/dbInspect.js'); + + // Precondition: helper built the prerequisite state correctly. + const pre = openInspector(harness.remote); + expect(pre.messagesAuthored(fx.remoteUser.id).length).toBe(2); + expect(pre.reactionsForUser(fx.remoteUser.id).length).toBe(2); + pre.close(); + + const res = await fetch(`${harness.home.origin}/api/users/@me/federation-identity/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.homeUser.token}` }, + body: JSON.stringify({ origins: [harness.remote.origin], mode: 'full' }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.results[harness.remote.origin].success).toBe(true); + + const remote = openInspector(harness.remote); + const after = remote.user(fx.remoteUser.id); + expect(after).toBeTruthy(); + expect(after!.isDeleted).toBe(1); + expect(after!.username).toBe(`!deleted:${fx.remoteUser.id}`); + + expect(remote.messagesAuthored(fx.remoteUser.id).length).toBe(0); // PURGED + expect(remote.reactionsForUser(fx.remoteUser.id).length).toBe(0); // PURGED + expect(remote.dmMembership(fx.remoteUser.id)).toEqual([]); + // 1-on-1 DM with another live participant SURVIVES (other party still member) + expect(remote.dmChannelExists(fx.dmChannelId)).toBe(true); + remote.close(); + }); }); diff --git a/packages/server/test/helpers/seedPeer.ts b/packages/server/test/helpers/seedPeer.ts index 7a7889b6..2d559df9 100644 --- a/packages/server/test/helpers/seedPeer.ts +++ b/packages/server/test/helpers/seedPeer.ts @@ -5,6 +5,21 @@ import type { SpawnedInstance } from './twoInstanceHarness.js'; * Install matching federation_peers rows on both instances pointing at each other, * with a single shared HMAC secret. Returns the secret for tests that need to sign * raw S2S requests directly. + * + * IMPORTANT — dual-origin reality in tests: + * Production code stores `peer.origin` as a single string and uses it for BOTH + * (a) outbound URL: `fetch(${peer.origin}/api/...)`, AND + * (b) inbound auth: `WHERE origin = X-Federation-Origin` claim from inbound headers. + * In production with `DOMAIN=example.com`, both reduce to `https://example.com`. + * + * In our test harness, the URL is `http://127.0.0.1:` but `getOurOrigin()` + * returns `https://${DOMAIN}` (= `https://home.test.local`). These two values are + * DIFFERENT, so we cannot satisfy both with one `peer.origin` row. + * + * Workaround: insert TWO rows per direction — one with the URL form (for outbound + * lookup on the sender) and one with the DOMAIN-claim form (for inbound auth on + * the receiver). The schema has a UNIQUE constraint on `origin`, but the two + * rows have distinct origins so there is no conflict. */ export async function peerInstances( a: SpawnedInstance, @@ -29,9 +44,17 @@ export async function peerInstances( } }; + // Outbound lookup form (URL) — what users.ts / federation.ts use to find the + // hmacSecret given a body-supplied or DB-stored origin URL. await seedOn(a, b.origin, b.domain); await seedOn(b, a.origin, a.domain); + // Inbound auth form (DOMAIN claim) — what the receiver uses to look up the + // peer when validating the X-Federation-Origin header from a sender whose + // `getOurOrigin()` returns `https://${DOMAIN}`. + await seedOn(a, `https://${b.domain}`, b.domain); + await seedOn(b, `https://${a.domain}`, a.domain); + return sharedSecret; }