feat(server): reclassify undeliverable targeted-peer as no_recipient failure (#18)
sendFederatedCallStart now treats a 200-with-undeliverable-messageId as a peer-failure instead of unconditional success. Feeds the existing failures[] array and terminal-determination machinery from #16. New sendFederatedCallStartForTest export mirrors the existing handleDm*ForTest pattern. TDD — three tests cover single-peer terminal no_recipient, group-DM mixed delivered+undeliverable non-terminal, and the happy-path (empty undeliverable → no event). Also hardens sendCallRelay's response parse: validates undeliverable is an Array and entries are well-shaped, logs protocol drift at warn/debug rather than silently falling back to old-peer semantics.
This commit is contained in:
@@ -644,10 +644,18 @@ export async function sendCallRelay(
|
|||||||
// omit the field; treat as empty. Body shape: FederationRelayResponse.
|
// omit the field; treat as empty. Body shape: FederationRelayResponse.
|
||||||
let undeliverable: string[] = [];
|
let undeliverable: string[] = [];
|
||||||
try {
|
try {
|
||||||
const body = (await res.json()) as { undeliverable?: Array<{ messageId: string }> };
|
const responseBody = (await res.json()) as { undeliverable?: unknown };
|
||||||
undeliverable = body.undeliverable?.map(u => u.messageId) ?? [];
|
if (Array.isArray(responseBody.undeliverable)) {
|
||||||
} catch {
|
undeliverable = responseBody.undeliverable
|
||||||
// Body missing or unparseable — assume old-format response.
|
.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 };
|
return { ok: true, undeliverable };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof drizzle<typeof schema>>;
|
||||||
|
let testDb: TestDb;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/federationAuth.js', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>(
|
||||||
|
'../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<typeof import('../utils/federationOutbox.js')>(
|
||||||
|
'../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<typeof import('../config.js')>('../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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1925,10 +1925,24 @@ async function sendFederatedCallStart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Targeted relay: fan out in parallel, await results ────────────────────
|
// ─── 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(
|
const targetedResults = await Promise.all(
|
||||||
Array.from(targetedPeers.keys()).map(async peerOrigin => {
|
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.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 };
|
return { origin: peerOrigin, ok: true as const };
|
||||||
}
|
}
|
||||||
const reason = mapCallReasonToEventReason(result.reason);
|
const reason = mapCallReasonToEventReason(result.reason);
|
||||||
@@ -2483,3 +2497,4 @@ export function registerCallRelayHooks(): void {
|
|||||||
export const handleDmCallAcceptForTest = handleDmCallAccept;
|
export const handleDmCallAcceptForTest = handleDmCallAccept;
|
||||||
export const handleDmCallRejectForTest = handleDmCallReject;
|
export const handleDmCallRejectForTest = handleDmCallReject;
|
||||||
export const handleDmCallEndForTest = handleDmCallEnd;
|
export const handleDmCallEndForTest = handleDmCallEnd;
|
||||||
|
export const sendFederatedCallStartForTest = sendFederatedCallStart;
|
||||||
|
|||||||
Reference in New Issue
Block a user