diff --git a/packages/server/test/federation-handshake-desync.test.ts b/packages/server/test/federation-handshake-desync.test.ts new file mode 100644 index 00000000..eca3eea3 --- /dev/null +++ b/packages/server/test/federation-handshake-desync.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + bootTwoInstancesForHandshake, + registerAdmin, + initiatePeering, + resetPeer, + getPeer, + s2sHealthy, + simulateReset, +} from './helpers/realHandshake.js'; +import type { TwoInstanceHarness } from './helpers/twoInstanceHarness.js'; +import { openInspector } from './helpers/dbInspect.js'; + +/** + * Acceptance-gate integration suite for the federation handshake desync bugs. + * + * These cases drive the REAL cross-instance `/peer/initiate`→`/peer/accept` + * handshake (via realHandshake.ts) rather than seeding peer rows. This is the + * RED baseline: #1 (control) passes on current code, #2 (BUG-1) and #4 (BUG-2) + * are EXPECTED to fail against current (unfixed) code — the fixes in later tasks + * turn them green. Do NOT modify product code to make #2/#4 pass here. + */ + +let h: TwoInstanceHarness; +afterEach(async () => { + if (h) await h.cleanup(); +}); + +describe('federation handshake desync (BUG-1/BUG-2)', () => { + it('#1 control: clean handshake activates both sides with matching secret', async () => { + h = await bootTwoInstancesForHandshake(); + const adminI = await registerAdmin(h.home); + await registerAdmin(h.remote); + + const { status } = await initiatePeering(h.home, adminI.token, h.remote); + expect(status).toBe(200); + + expect(await s2sHealthy(h.home, h.remote)).toBe(true); + expect(await s2sHealthy(h.remote, h.home)).toBe(true); + + const homeInsp = openInspector(h.home); + const remoteInsp = openInspector(h.remote); + try { + const iSecret = homeInsp.federationPeer(h.remote.origin)?.hmacSecret; + const rSecret = remoteInsp.federationPeer(h.home.origin)?.hmacSecret; + expect(iSecret).toBeTruthy(); + expect(rSecret).toBeTruthy(); + expect(iSecret).toBe(rSecret); + } finally { + homeInsp.close(); + remoteInsp.close(); + } + }, 60_000); + + it('#2 BUG-1: initiator must NOT false-activate against a survivor that holds a row', async () => { + h = await bootTwoInstancesForHandshake(); + const adminI = await registerAdmin(h.home); + await registerAdmin(h.remote); + + // Establish peering, then reset home (fresh incarnation, no row). + // remote still holds its old active row for home's origin. + await initiatePeering(h.home, adminI.token, h.remote); + expect(await s2sHealthy(h.home, h.remote)).toBe(true); + + const freshHome = await simulateReset(h.home, h); + const adminI2 = await registerAdmin(freshHome); + + // Fresh home re-initiates into the survivor remote (which still holds its old active row). + const { status, body } = await initiatePeering(freshHome, adminI2.token, h.remote); + + // FIX (green): remote refuses honestly with 409; fresh home does NOT end up active-but-dead. + expect(status).toBe(409); + expect(body.code).toBe('PEER_EXISTS_RESET_REQUIRED'); + + // Fresh home must hold no false-active row for remote's origin. + const insp = openInspector(freshHome); + try { + const iRow = insp.federationPeer(h.remote.origin); + expect(iRow?.status === 'active').toBe(false); + } finally { + insp.close(); + } + }, 60_000); + + it('#4 BUG-2: one-click Re-peer never reports success on a dead peering; genuinely recovers', async () => { + h = await bootTwoInstancesForHandshake(); + const adminI = await registerAdmin(h.home); + const adminR = await registerAdmin(h.remote); + + await initiatePeering(h.home, adminI.token, h.remote); + expect(await s2sHealthy(h.home, h.remote)).toBe(true); + + const freshHome = await simulateReset(h.home, h); + const adminIf = await registerAdmin(freshHome); + + // Fresh home initiates into remote → triggers detection (remote's row moves to + // needs_attention via markPeerReset). On buggy code fresh home ALSO false-activates; + // after the fix fresh home gets 409 and stays clean. + await initiatePeering(freshHome, adminIf.token, h.remote); + + // One-click Re-peer on the SURVIVOR (remote): detection must have fired. + const rPeer = await getPeer(h.remote, adminR.token, freshHome.origin); + expect(rPeer).toBeTruthy(); + expect(rPeer!.status).toBe('needs_attention'); + + // Reset remote's stale row, then re-initiate the handshake from remote. + await resetPeer(h.remote, adminR.token, rPeer!.id); + const rebuilt = await initiatePeering(h.remote, adminR.token, freshHome); + + // GENUINE recovery: 200 + working secret in both directions + matching stored secrets. + expect(rebuilt.status).toBe(200); + expect(await s2sHealthy(h.remote, freshHome)).toBe(true); + expect(await s2sHealthy(freshHome, h.remote)).toBe(true); + + const remoteInsp = openInspector(h.remote); + const homeInsp = openInspector(freshHome); + try { + const rSecret = remoteInsp.federationPeer(freshHome.origin)?.hmacSecret; + const iSecret = homeInsp.federationPeer(h.remote.origin)?.hmacSecret; + expect(rSecret).toBeTruthy(); + expect(iSecret).toBeTruthy(); + expect(rSecret).toBe(iSecret); + } finally { + remoteInsp.close(); + homeInsp.close(); + } + }, 60_000); +});