diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index ea5ca260..b078f438 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -644,10 +644,18 @@ export async function sendCallRelay( // omit the field; treat as empty. Body shape: FederationRelayResponse. let undeliverable: string[] = []; try { - const body = (await res.json()) as { undeliverable?: Array<{ messageId: string }> }; - undeliverable = body.undeliverable?.map(u => u.messageId) ?? []; - } catch { - // Body missing or unparseable — assume old-format response. + const responseBody = (await res.json()) as { undeliverable?: unknown }; + if (Array.isArray(responseBody.undeliverable)) { + undeliverable = responseBody.undeliverable + .filter((u): u is { messageId: string } => + typeof u === 'object' && u !== null && typeof (u as { messageId?: unknown }).messageId === 'string', + ) + .map(u => u.messageId); + } else if (responseBody.undeliverable !== undefined) { + console.warn('[federation] sendCallRelay: peer returned non-array undeliverable, ignoring:', targetPeerOrigin); + } + } catch (err) { + console.debug('[federation] sendCallRelay: response body unparseable, treating as old-format:', targetPeerOrigin, err); } return { ok: true, undeliverable }; } diff --git a/packages/server/src/ws/events.callStartUndeliverable.test.ts b/packages/server/src/ws/events.callStartUndeliverable.test.ts new file mode 100644 index 00000000..4293a7c6 --- /dev/null +++ b/packages/server/src/ws/events.callStartUndeliverable.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +vi.mock('../utils/federationAuth.js', async () => { + const actual = await vi.importActual( + '../utils/federationAuth.js', + ); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + buildFederationHeaders: () => ({}), + generateFederatedCallToken: () => Promise.resolve('fake-token'), + }; +}); + +// Mock sendCallRelay so the test controls relay results per peer. The +// implementation captures the messageId each call was made with so tests +// can return { undeliverable: [messageId] } dynamically. +type RelayArgs = [string, Array<{ messageId: string }>]; +const sendCallRelayMock = vi.fn(); +vi.mock('../utils/federationOutbox.js', async () => { + const actual = await vi.importActual( + '../utils/federationOutbox.js', + ); + return { + ...actual, + sendCallRelay: (...args: RelayArgs) => sendCallRelayMock(...args), + }; +}); + +// Mock config to claim LiveKit is configured. +vi.mock('../config.js', async () => { + const actual = await vi.importActual('../config.js'); + return { + ...actual, + config: { + ...actual.config, + domain: 'local.example', + livekit: { + url: 'wss://local.example/livekit', + apiKey: 'key', + apiSecret: 'secret', + }, + }, + }; +}); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sql.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedActivePeer(origin: string, instanceName: string): void { + testDb.insert(schema.federationPeers).values({ + id: `peer-${origin}`, + origin, + hmacSecret: 'secret', + status: 'active', + instanceName, + lastSyncedAt: 0, + createdAt: Date.now(), + }).run(); +} + +function seedLocalUser(id: string, opts: { homeUserId?: string | null; homeInstance?: string | null } = {}): void { + testDb.insert(schema.users).values({ + id, + username: id, + passwordHash: 'test', + homeUserId: opts.homeUserId ?? null, + homeInstance: opts.homeInstance ?? null, + createdAt: Date.now(), + }).run(); +} + +function seedDmChannel(id: string, federatedId: string, ownerId: string | null): void { + testDb.insert(schema.dmChannels).values({ + id, + ownerId, + federatedId, + createdAt: Date.now(), + }).run(); +} + +function seedDmMember(dmChannelId: string, userId: string): void { + testDb.insert(schema.dmMembers).values({ dmChannelId, userId }).run(); +} + +async function importSUT() { + return await import('./events.js'); +} + +async function importManager() { + return (await import('./handler.js')).connectionManager; +} + +let sqlite: Database.Database; + +describe('sendFederatedCallStart — undeliverable reclassification (#18)', () => { + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + + const cm = await importManager(); + // Reset federatedCalls + rooms between tests. + for (const [fedId] of cm.getAllFederatedCalls()) cm.clearFederatedCall(fedId); + sendCallRelayMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('single targeted peer returns undeliverable → terminal dm_call_undeliverable, room destroyed', async () => { + // 1-on-1 DM: Alice local, Bob remote on orbit. + const federatedId = 'fed-1on1'; + seedLocalUser('alice', { homeUserId: null, homeInstance: null }); + seedLocalUser('bob-stub', { homeUserId: 'bob-home', homeInstance: 'https://orbit.example' }); + seedDmChannel('dm-1', federatedId, null); + seedDmMember('dm-1', 'alice'); + seedDmMember('dm-1', 'bob-stub'); + seedActivePeer('https://orbit.example', 'Orbit'); + + const cm = await importManager(); + cm.createDmRoom('dm-1', 'alice'); // caller's local ring room (mirrors real flow) + + // Capture the messageId sendFederatedCallStart generates, return it as undeliverable. + sendCallRelayMock.mockImplementation(async (_origin: string, events: Array<{ messageId: string }>) => { + return { ok: true, undeliverable: [events[0]!.messageId] }; + }); + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + const destroyRoomSpy = vi.spyOn(cm, 'destroyRoom'); + + const { sendFederatedCallStartForTest } = await importSUT(); + await sendFederatedCallStartForTest('dm-1', 'alice', 'Alice'); + + // The caller (Alice) got a terminal dm_call_undeliverable with reason='no_recipient'. + const undelivCalls = sendToUserSpy.mock.calls.filter(([, ev]) => + (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(undelivCalls).toHaveLength(1); + expect(undelivCalls[0]![0]).toBe('alice'); + + const ev = undelivCalls[0]![1] as { + terminal: boolean; + phase: string; + failures: Array<{ reason: string; peerLabel?: string; peerOrigin?: string }>; + }; + expect(ev.terminal).toBe(true); + expect(ev.phase).toBe('start'); + expect(ev.failures).toHaveLength(1); + expect(ev.failures[0]!.reason).toBe('no_recipient'); + expect(ev.failures[0]!.peerOrigin).toBe('https://orbit.example'); + expect(ev.failures[0]!.peerLabel).toBe('Orbit'); + + // Room was destroyed. + expect(destroyRoomSpy).toHaveBeenCalledWith('dm-1'); + }); + + it('group DM mixed delivered + undeliverable → non-terminal, failures lists only the undeliverable peer', async () => { + // Group DM: caller + one member on orbit (delivers) + one member on nova (undeliverable). + const federatedId = 'fed-group'; + seedLocalUser('alice', { homeUserId: null, homeInstance: null }); + seedLocalUser('bob-stub', { homeUserId: 'bob-home', homeInstance: 'https://orbit.example' }); + seedLocalUser('carol-stub', { homeUserId: 'carol-home', homeInstance: 'https://nova.example' }); + seedDmChannel('dm-group', federatedId, 'alice'); // group DM: ownerId non-null + seedDmMember('dm-group', 'alice'); + seedDmMember('dm-group', 'bob-stub'); + seedDmMember('dm-group', 'carol-stub'); + seedActivePeer('https://orbit.example', 'Orbit'); + seedActivePeer('https://nova.example', 'Nova'); + + const cm = await importManager(); + cm.createDmRoom('dm-group', 'alice'); + + // Orbit delivers (empty undeliverable), Nova returns messageId in undeliverable. + sendCallRelayMock.mockImplementation(async (origin: string, events: Array<{ messageId: string }>) => { + if (origin === 'https://nova.example') { + return { ok: true, undeliverable: [events[0]!.messageId] }; + } + return { ok: true, undeliverable: [] }; + }); + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + const destroyRoomSpy = vi.spyOn(cm, 'destroyRoom'); + + const { sendFederatedCallStartForTest } = await importSUT(); + await sendFederatedCallStartForTest('dm-group', 'alice', 'Alice'); + + // Room NOT destroyed (orbit delivered). + expect(destroyRoomSpy).not.toHaveBeenCalledWith('dm-group'); + + const undelivCalls = sendToUserSpy.mock.calls.filter(([uid, ev]) => + uid === 'alice' && (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(undelivCalls).toHaveLength(1); + + const ev = undelivCalls[0]![1] as { + terminal: boolean; + failures: Array<{ reason: string; peerOrigin?: string }>; + }; + expect(ev.terminal).toBe(false); + expect(ev.failures).toHaveLength(1); + expect(ev.failures[0]!.reason).toBe('no_recipient'); + expect(ev.failures[0]!.peerOrigin).toBe('https://nova.example'); + }); + + it('single targeted peer delivers (empty undeliverable) → no undeliverable event', async () => { + const federatedId = 'fed-happy'; + seedLocalUser('alice', {}); + seedLocalUser('bob-stub', { homeUserId: 'bob-home', homeInstance: 'https://orbit.example' }); + seedDmChannel('dm-happy', federatedId, null); + seedDmMember('dm-happy', 'alice'); + seedDmMember('dm-happy', 'bob-stub'); + seedActivePeer('https://orbit.example', 'Orbit'); + + const cm = await importManager(); + cm.createDmRoom('dm-happy', 'alice'); + + sendCallRelayMock.mockResolvedValue({ ok: true, undeliverable: [] }); + + const sendToUserSpy = vi.spyOn(cm, 'sendToUser'); + const { sendFederatedCallStartForTest } = await importSUT(); + await sendFederatedCallStartForTest('dm-happy', 'alice', 'Alice'); + + const undelivCalls = sendToUserSpy.mock.calls.filter(([, ev]) => + (ev as { type: string }).type === 'dm_call_undeliverable', + ); + expect(undelivCalls).toHaveLength(0); + }); +}); diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 61955143..6aaa859c 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -1925,10 +1925,24 @@ async function sendFederatedCallStart( } // ─── Targeted relay: fan out in parallel, await results ──────────────────── + // Each peer's result has THREE possible classifications: + // ok=true, messageId NOT in undeliverable → delivered + // ok=true, messageId IN undeliverable → new: no_recipient failure (#18) + // ok=false → existing failure reasons const targetedResults = await Promise.all( Array.from(targetedPeers.keys()).map(async peerOrigin => { - const result = await sendCallRelay(peerOrigin, [buildRelayEvent()]); + const relayEvent = buildRelayEvent(); + const result = await sendCallRelay(peerOrigin, [relayEvent]); if (result.ok) { + if (result.undeliverable.includes(relayEvent.messageId)) { + console.warn(`[federation] dm_call_start to ${peerOrigin}: remote had no recipient`); + return { + origin: peerOrigin, + ok: false as const, + reason: 'no_recipient' as const satisfies DmCallUndeliverableReason, + error: 'remote reported no_recipient', + }; + } return { origin: peerOrigin, ok: true as const }; } const reason = mapCallReasonToEventReason(result.reason); @@ -2483,3 +2497,4 @@ export function registerCallRelayHooks(): void { export const handleDmCallAcceptForTest = handleDmCallAccept; export const handleDmCallRejectForTest = handleDmCallReject; export const handleDmCallEndForTest = handleDmCallEnd; +export const sendFederatedCallStartForTest = sendFederatedCallStart;