diff --git a/packages/server/test/helpers/dbInspect.ts b/packages/server/test/helpers/dbInspect.ts index 89fe4abb..78b33f82 100644 --- a/packages/server/test/helpers/dbInspect.ts +++ b/packages/server/test/helpers/dbInspect.ts @@ -31,6 +31,7 @@ export interface DbInspector { dmChannelExists(dmChannelId: string): boolean; registryRow(userId: string, origin: string): unknown; replicatedInstancesArray(userId: string): { origin: string }[]; + federationPeer(origin: string): { origin: string; hmacSecret: string; status: string; peerInstanceId: string | null; needsAttentionReason: string | null } | null; close(): void; } @@ -91,6 +92,14 @@ export function openInspector(instance: SpawnedInstance): DbInspector { if (!row?.replicatedInstances) return []; try { return JSON.parse(row.replicatedInstances); } catch { return []; } }, + federationPeer: (origin) => db.prepare(` + SELECT origin, + hmac_secret AS hmacSecret, + status, + peer_instance_id AS peerInstanceId, + needs_attention_reason AS needsAttentionReason + FROM federation_peers WHERE origin = ? + `).get(origin) as { origin: string; hmacSecret: string; status: string; peerInstanceId: string | null; needsAttentionReason: string | null } | null, close: () => db.close(), }; } diff --git a/packages/server/test/helpers/realHandshake.ts b/packages/server/test/helpers/realHandshake.ts new file mode 100644 index 00000000..d371f189 --- /dev/null +++ b/packages/server/test/helpers/realHandshake.ts @@ -0,0 +1,214 @@ +import { + bootHomePlusRemotes, + spawnInstance, + type SpawnedInstance, + type TwoInstanceHarness, +} from './twoInstanceHarness.js'; +import { registerLocal } from './testUsers.js'; +import { openInspector } from './dbInspect.js'; +import { buildHeadersForOrigin } from './hmacSign.js'; + +/** + * Real-handshake two-instance harness helpers. + * + * Unlike `seedPeer.ts` (which SEEDS matching peer rows via a test route and so + * never exercises the real `/peer/initiate`→`/peer/accept` handshake — where the + * bugs live), these helpers drive the REAL handshake between two localhost + * instances. + * + * The single enabling trick: each instance is spawned with + * `PUBLIC_ORIGIN=http://127.0.0.1:` (see `bootHomePlusRemotes`'s + * `publicOriginAsTransport` flag). That makes `getOurOrigin()` return the + * TRANSPORT url instead of the identity `https://`, so a single real + * handshake creates exactly one reachable peer row per direction, keyed by the + * transport origin. Outbound fetch, inbound HMAC lookup (`X-Federation-Origin`), + * and the `/api/federation/epoch` peer lookup all then agree on that one origin. + * + * Cleanup of RESPAWNED (reset) processes: `simulateReset` registers each fresh + * process against its harness in a module-level map, and the `cleanup()` returned + * by `bootTwoInstancesForHandshake` drains that map (killing any respawned procs) + * before delegating to the base harness cleanup. Tests therefore only need to + * call `harness.cleanup()` in `afterAll` — respawned procs are handled for them. + */ + +/** + * Shape of a peer as returned by `GET /api/federation/peers` (`sanitizePeer`) + * and `POST /api/federation/peer/initiate`. Kept permissive (index signature) + * so callers can read fields not enumerated here without a cast. + */ +export interface FederationPeerLike { + id: string; + origin: string; + instanceName: string | null; + status: string; + needsAttentionReason: 'auth_failures' | 'peer_reset_detected' | 'repeer_incomplete' | null; + [key: string]: unknown; +} + +// Tracks processes respawned by `simulateReset` so `harness.cleanup()` can kill +// them (they are NOT in the base harness's [home, ...remotes] list). +const respawnedByHarness = new WeakMap(); + +/** + * Boot 1 home + 1 remote, each with `PUBLIC_ORIGIN` = its own transport origin. + * Same shape as `bootTwoInstances`, but the returned `cleanup()` also kills any + * processes created by `simulateReset` against this harness. + */ +export async function bootTwoInstancesForHandshake(): Promise { + const m = await bootHomePlusRemotes(1, { publicOriginAsTransport: true }); + const harness: TwoInstanceHarness = { + home: m.home, + remote: m.remotes[0]!, + remotes: m.remotes, + runDir: m.runDir, + cleanup: async () => {}, + }; + harness.cleanup = async () => { + const extra = respawnedByHarness.get(harness) ?? []; + for (const inst of extra) { + if (!inst.proc.killed) { + inst.proc.kill('SIGTERM'); + await new Promise(r => setTimeout(r, 300)); + if (!inst.proc.killed) inst.proc.kill('SIGKILL'); + } + } + respawnedByHarness.delete(harness); + await m.cleanup(); + }; + return harness; +} + +/** + * Register the FIRST user on a fresh instance, which auto-becomes admin + * (verified auth.ts:203-223: `isFirstUser = userCount === 0 && !homeInstance`). + * Returns just the admin JWT. + */ +export async function registerAdmin(inst: SpawnedInstance): Promise<{ token: string }> { + const u = await registerLocal(inst, 'admin'); + return { token: u.token }; +} + +/** + * Drive the real handshake: `POST /api/federation/peer/initiate` on `fromInst` + * targeting `toInst.origin`. Returns the HTTP status and parsed body (tolerating + * a non-JSON body → `{}`). + */ +export async function initiatePeering( + fromInst: SpawnedInstance, + adminToken: string, + toInst: SpawnedInstance, +): Promise<{ status: number; body: { peer?: FederationPeerLike; verified?: boolean; code?: string; error?: string } }> { + const res = await fetch(`${fromInst.origin}/api/federation/peer/initiate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${adminToken}`, + }, + body: JSON.stringify({ remoteOrigin: toInst.origin }), + }); + let body: { peer?: FederationPeerLike; verified?: boolean; code?: string; error?: string } = {}; + try { + body = await res.json() as typeof body; + } catch { + body = {}; + } + return { status: res.status, body }; +} + +/** + * `POST /api/federation/peers/:id/reset` — reset a peer that needs attention. + * Admin-authenticated. Returns just the HTTP status. + */ +export async function resetPeer( + inst: SpawnedInstance, + adminToken: string, + peerId: string, +): Promise<{ status: number }> { + const res = await fetch(`${inst.origin}/api/federation/peers/${peerId}/reset`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${adminToken}` }, + }); + return { status: res.status }; +} + +/** + * `GET /api/federation/peers` — find the peer row keyed by `origin`. Peers are + * keyed by transport origin under `PUBLIC_ORIGIN`, so pass e.g. `toInst.origin`. + * Returns null if no matching peer exists. + */ +export async function getPeer( + inst: SpawnedInstance, + adminToken: string, + origin: string, +): Promise { + const res = await fetch(`${inst.origin}/api/federation/peers`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${adminToken}` }, + }); + if (!res.ok) return null; + const data = await res.json() as { peers?: FederationPeerLike[] }; + const peers = data.peers ?? []; + return peers.find(p => p.origin === origin) ?? null; +} + +export async function s2sHealthy(fromInst: SpawnedInstance, toInst: SpawnedInstance): Promise { + const insp = openInspector(fromInst); + try { + const peer = insp.federationPeer(toInst.origin); + if (!peer) return false; + const body = JSON.stringify({}); + // fromInst's getOurOrigin() === fromInst.origin (PUBLIC_ORIGIN), which is + // the origin toInst stored for the peer row — so the /epoch lookup matches. + const headers = buildHeadersForOrigin(body, peer.hmacSecret, fromInst.origin); + const res = await fetch(`${toInst.origin}/api/federation/epoch`, { method: 'POST', headers, body }); + if (!res.ok) return false; + // Response is HMAC-signed with the same secret; a healthy link returns a + // valid signed {instanceId}. A 200 with parseable instanceId is sufficient + // proof the shared secret authenticates in this direction. + const text = await res.text(); + return typeof (JSON.parse(text) as { instanceId?: string }).instanceId === 'string'; + } catch { + return false; + } finally { + insp.close(); + } +} + +/** + * Spawn a fresh instance reusing the same port + domain + secrets as a killed + * instance, with `PUBLIC_ORIGIN` pinned to that port so peers keep addressing it + * at the same origin. Thin wrapper over `spawnInstance` (DRY — no duplicated + * spawn logic). + */ +async function spawnInstanceForReset(opts: { + domain: string; + port: number; + dbPath: string; + storagePath: string; + jwtSecret: string; + logPath: string; +}): Promise { + return spawnInstance({ ...opts, publicOriginAsTransport: true }); +} + +export async function simulateReset(inst: SpawnedInstance, harness: TwoInstanceHarness): Promise { + inst.proc.kill('SIGTERM'); + await new Promise(r => setTimeout(r, 600)); + if (!inst.proc.killed) inst.proc.kill('SIGKILL'); + await new Promise(r => setTimeout(r, 200)); + // Fresh DB on the SAME port + domain → "factory reset on the same domain": + // new instance_id, no peer rows, but peers still address it at the same origin. + const freshDbPath = `${harness.runDir}/${inst.domain}-reset-${Date.now()}.db`; + const fresh = await spawnInstanceForReset({ + domain: inst.domain, port: inst.port, dbPath: freshDbPath, + storagePath: inst.storagePath, jwtSecret: inst.jwtSecret, + logPath: `${harness.runDir}/${inst.domain}-reset.log`, + }); + // Register for cleanup: the base harness's [home, ...remotes] list holds only + // the ORIGINAL (now-dead) handle, so `harness.cleanup()` would otherwise leak + // this respawned process. bootTwoInstancesForHandshake drains this list. + const list = respawnedByHarness.get(harness) ?? []; + list.push(fresh); + respawnedByHarness.set(harness, list); + return fresh; +} diff --git a/packages/server/test/helpers/twoInstanceHarness.ts b/packages/server/test/helpers/twoInstanceHarness.ts index 516f91dd..9f68e807 100644 --- a/packages/server/test/helpers/twoInstanceHarness.ts +++ b/packages/server/test/helpers/twoInstanceHarness.ts @@ -82,7 +82,7 @@ async function waitForReady(origin: string, proc: ChildProcess, logPath: string, } } -async function spawnInstance(opts: { +export async function spawnInstance(opts: { domain: string; port: number; dbPath: string; @@ -90,7 +90,17 @@ async function spawnInstance(opts: { jwtSecret: string; logPath: string; disableRateLimits?: boolean; + /** + * When true, set `PUBLIC_ORIGIN=http://127.0.0.1:` so this instance's + * `getOurOrigin()` returns its TRANSPORT url instead of the identity + * `https://`. Required by the real-handshake harness so a single real + * `/peer/initiate`→`/peer/accept` creates one working, reachable peer row per + * direction (keyed by the transport origin). Default off — existing callers + * are unaffected. See realHandshake.ts and federationAuth.ts:187. + */ + publicOriginAsTransport?: boolean; }): Promise { + const origin = `http://127.0.0.1:${opts.port}`; const env: Record = { ...process.env, NODE_ENV: 'test', @@ -112,6 +122,9 @@ async function spawnInstance(opts: { if (opts.disableRateLimits !== false) { env.DISABLE_RATE_LIMITS = '1'; } + if (opts.publicOriginAsTransport) { + env.PUBLIC_ORIGIN = origin; + } // From packages/server/test/helpers → packages/server is up two levels. const serverDir = path.resolve(__dirname, '../../'); const proc = spawn('pnpm', ['exec', 'tsx', 'src/index.ts'], { @@ -122,7 +135,6 @@ async function spawnInstance(opts: { const logStream = createWriteStream(opts.logPath); proc.stdout!.pipe(logStream); proc.stderr!.pipe(logStream); - const origin = `http://127.0.0.1:${opts.port}`; await waitForReady(origin, proc, opts.logPath); return { proc, @@ -143,6 +155,13 @@ export interface BootOptions { * tests that assert rate-limit behaviour (Test #15). Default: false (bypass on). */ enableRateLimits?: boolean; + /** + * If true, every spawned instance (home + all remotes) sets + * `PUBLIC_ORIGIN=http://127.0.0.1:` so `getOurOrigin()` returns the + * transport url. Required by the real-handshake harness so a single real + * handshake yields one reachable peer row per direction. Default: false. + */ + publicOriginAsTransport?: boolean; } /** Boot home + N remotes. All instances ready before the function returns. */ @@ -152,6 +171,7 @@ export async function bootHomePlusRemotes( ): Promise { if (remoteCount < 1) throw new Error('remoteCount must be >= 1'); const disableRateLimits = !options.enableRateLimits; + const publicOriginAsTransport = options.publicOriginAsTransport ?? false; const runId = crypto.randomBytes(4).toString('hex'); // From packages/server/test/helpers → repo root is up four levels: helpers → test → server → packages → repo-root. const runDir = path.resolve(__dirname, `../../../../tests/.tmp/${runId}`); @@ -166,6 +186,7 @@ export async function bootHomePlusRemotes( jwtSecret: crypto.randomBytes(32).toString('hex'), logPath: `${runDir}/home.log`, disableRateLimits, + publicOriginAsTransport, }); const remotes: SpawnedInstance[] = []; @@ -180,6 +201,7 @@ export async function bootHomePlusRemotes( jwtSecret: crypto.randomBytes(32).toString('hex'), logPath: `${runDir}/remote${i}.log`, disableRateLimits, + publicOriginAsTransport, }); remotes.push(r); }