From 957cfd909473a38868e5abfe2c08164b57ab3c8c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 23:38:02 +0200 Subject: [PATCH] =?UTF-8?q?test(federation-identity):=20#1=20leave=20mode?= =?UTF-8?q?=20=E2=80=94=20registry=20cleaned,=20remote=20untouched,=20no?= =?UTF-8?q?=20S2S=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix dbInspect.ts UserRow column aliases: SELECT * returns snake_case columns (is_deleted, display_name, etc.) but UserRow expected camelCase. Switch to explicit aliased SELECT so all callers get the documented interface. --- .../test/federation-identity-deletion.test.ts | 47 +++++++++++++++++++ packages/server/test/helpers/dbInspect.ts | 34 +++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/server/test/federation-identity-deletion.test.ts b/packages/server/test/federation-identity-deletion.test.ts index d3a469c3..56c34cc9 100644 --- a/packages/server/test/federation-identity-deletion.test.ts +++ b/packages/server/test/federation-identity-deletion.test.ts @@ -46,4 +46,51 @@ describe('Federation identity deletion — server suite', () => { const body = await res.json(); expect(String(body.error)).toMatch(/origins/); }); + + it('#1 leave mode: home registry cleaned, remote untouched, no S2S request hits the remote', async () => { + const { createFederatedUser } = await import('./helpers/testUsers.js'); + const { openInspector } = await import('./helpers/dbInspect.js'); + const { logMatched } = await import('./helpers/twoInstanceHarness.js'); + const { homeUser, remoteUser } = await createFederatedUser(harness.home, harness.remote, 't1'); + + // Snapshot remote user pre-delete + const remoteInspect = openInspector(harness.remote); + const before = remoteInspect.user(remoteUser.id); + expect(before).toBeTruthy(); + expect(before!.isDeleted).toBe(0); + remoteInspect.close(); + + const res = await fetch(`${harness.home.origin}/api/users/@me/federation-identity/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${homeUser.token}` }, + body: JSON.stringify({ origins: [harness.remote.origin], mode: 'leave' }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.results[harness.remote.origin]).toEqual({ success: true }); + + // CRITICAL: prove no S2S call hit the remote. Fastify access logs include the + // request path; if `/api/federation/identity` appears at all in remote.log + // after the call, leave mode incorrectly fired an S2S request. + const sawS2S = await logMatched(harness.remote, /\/api\/federation\/identity/, 1_000); + expect(sawS2S).toBe(false); + + // Belt-and-suspenders: remote user row UNCHANGED in every column + const remoteAfter = openInspector(harness.remote); + const after = remoteAfter.user(remoteUser.id); + expect(after).toMatchObject({ + id: before!.id, + username: before!.username, + isDeleted: 0, + displayName: before!.displayName, + }); + remoteAfter.close(); + + // Home registry row gone, replicatedInstances trimmed + const homeInspect = openInspector(harness.home); + expect(homeInspect.registryRow(homeUser.id, harness.remote.origin)).toBeUndefined(); + const replInst = homeInspect.replicatedInstancesArray(homeUser.id); + expect(replInst.find(r => r.origin === harness.remote.origin)).toBeUndefined(); + homeInspect.close(); + }); }); diff --git a/packages/server/test/helpers/dbInspect.ts b/packages/server/test/helpers/dbInspect.ts index 465ad583..89fe4abb 100644 --- a/packages/server/test/helpers/dbInspect.ts +++ b/packages/server/test/helpers/dbInspect.ts @@ -38,8 +38,38 @@ export function openInspector(instance: SpawnedInstance): DbInspector { const db = new Database(instance.dbPath, { readonly: true, fileMustExist: true }); db.pragma('journal_mode = WAL'); return { - user: (uid) => db.prepare('SELECT * FROM users WHERE id = ?').get(uid) as UserRow | null, - userByUsername: (u) => db.prepare('SELECT * FROM users WHERE username = ?').get(u) as UserRow | null, + user: (uid) => db.prepare(` + SELECT id, username, + password_hash AS passwordHash, + display_name AS displayName, + avatar, banner, bio, + custom_status AS customStatus, + accent_color AS accentColor, + avatar_color AS avatarColor, + replicated_instances AS replicatedInstances, + is_deleted AS isDeleted, + status, + is_admin AS isAdmin, + home_instance AS homeInstance, + home_user_id AS homeUserId + FROM users WHERE id = ? + `).get(uid) as UserRow | null, + userByUsername: (u) => db.prepare(` + SELECT id, username, + password_hash AS passwordHash, + display_name AS displayName, + avatar, banner, bio, + custom_status AS customStatus, + accent_color AS accentColor, + avatar_color AS avatarColor, + replicated_instances AS replicatedInstances, + is_deleted AS isDeleted, + status, + is_admin AS isAdmin, + home_instance AS homeInstance, + home_user_id AS homeUserId + FROM users WHERE username = ? + `).get(u) as UserRow | null, spaceMembersForUser: (uid) => db.prepare('SELECT space_id AS spaceId, user_id AS userId FROM space_members WHERE user_id = ?').all(uid) as { spaceId: string; userId: string }[], reactionsForUser: (uid) =>