Files
backspace/packages/server/src/utils/federationPeerActivation.test.ts
T
Jannis Braun 2dbd2b9b9f test(server): add undeliverable:[] to processRelayEvents mock (#18)
Follow-up from Task 2 code review. Keeps the test mock aligned with
the widened return type even though vi.mock doesn't structurally
typecheck the factory.
2026-04-24 21:24:18 +02:00

455 lines
19 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } 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 { eq } from 'drizzle-orm';
import * as federationRouteMock from '../routes/federation.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
schema,
}));
vi.mock('../utils/federationOutbox.js', () => ({
isFederationRelayEnabled: () => true,
}));
vi.mock('../utils/federationAuth.js', () => ({
getOurOrigin: () => 'https://local.example',
buildFederationHeaders: (_body: string, _secret: string, _origin: string) => ({
'Content-Type': 'application/json',
'X-Federation-Origin': _origin,
}),
}));
vi.mock('../routes/federation.js', () => ({
processRelayEvents: vi.fn().mockResolvedValue({ accepted: [], rejected: [], undeliverable: [] }),
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
sendToUser: vi.fn(),
sendToDmMembers: vi.fn(),
evictFederatedCallsForHost: vi.fn().mockReturnValue(0),
},
}));
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 seedPeer(id: string, status: string, lastSyncedAt = 0): void {
testDb.insert(schema.federationPeers).values({
id, origin: `https://${id}.example`, hmacSecret: 'secret',
status, lastSyncedAt, createdAt: Date.now(),
}).run();
}
function seedOutboxEntry(id: string, peerId: string, nextRetryAt: number, attempts: number): void {
testDb.insert(schema.federationOutbox).values({
id, peerId, contextId: 'ch-1', entityId: `msg-${id}`,
contextType: 'dm', eventType: 'create', payload: '{}',
encryptionVersion: 0, attempts, nextRetryAt,
expiresAt: Date.now() + 30 * 86_400_000,
createdAt: Date.now(),
}).run();
}
describe('resetOutboxBackoff', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
});
it('resets nextRetryAt=now and attempts=0 for all peer entries — including past-due ones', async () => {
const { resetOutboxBackoff } = await import('./federationPeerActivation.js');
seedPeer('peer-a', 'active');
seedPeer('peer-b', 'active');
const now = Date.now();
// Three entries for peer-a: past-due (already eligible), near-future, far-future
seedOutboxEntry('entry-1', 'peer-a', now - 1000, 5);
seedOutboxEntry('entry-2', 'peer-a', now + 60_000, 3);
seedOutboxEntry('entry-3', 'peer-a', now + 86_400_000, 7);
// Entry for unrelated peer-b (must NOT be touched)
seedOutboxEntry('entry-4', 'peer-b', now + 86_400_000, 9);
resetOutboxBackoff('peer-a');
const a1 = testDb.select().from(schema.federationOutbox).where(eq(schema.federationOutbox.id, 'entry-1')).get();
const a2 = testDb.select().from(schema.federationOutbox).where(eq(schema.federationOutbox.id, 'entry-2')).get();
const a3 = testDb.select().from(schema.federationOutbox).where(eq(schema.federationOutbox.id, 'entry-3')).get();
const b4 = testDb.select().from(schema.federationOutbox).where(eq(schema.federationOutbox.id, 'entry-4')).get();
// All peer-a entries reset — including the past-due one (correctness: attempts=0 on those too)
expect(a1?.attempts).toBe(0);
expect(a2?.attempts).toBe(0);
expect(a3?.attempts).toBe(0);
expect(a1?.nextRetryAt).toBeGreaterThanOrEqual(now);
expect(a2?.nextRetryAt).toBeLessThanOrEqual(Date.now());
expect(a3?.nextRetryAt).toBeLessThanOrEqual(Date.now());
// peer-b untouched
expect(b4?.attempts).toBe(9);
expect(b4?.nextRetryAt).toBe(now + 86_400_000);
});
it('is a no-op when the peer has no outbox entries', async () => {
const { resetOutboxBackoff } = await import('./federationPeerActivation.js');
seedPeer('peer-empty', 'active');
expect(() => resetOutboxBackoff('peer-empty')).not.toThrow();
});
});
describe('syncPeerMutationLog', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
vi.restoreAllMocks();
});
it('seeds sinceTimestamp from peer.lastSyncedAt for each pass', async () => {
const { syncPeerMutationLog } = await import('./federationPeerActivation.js');
testDb.insert(schema.federationPeers).values({
id: 'peer-1', origin: 'https://peer-1.example', hmacSecret: 'secret',
status: 'active', lastSyncedAt: 5000, createdAt: Date.now(),
}).run();
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({ events: [], hasMore: false, checkpoint: 5000 }), { status: 200 })
);
await syncPeerMutationLog('peer-1', 'health_check_recovery');
// Three passes: dm (no contextType), friend, profile
expect(fetchSpy).toHaveBeenCalledTimes(3);
for (const call of fetchSpy.mock.calls) {
const body = JSON.parse(call[1]?.body as string) as { sinceTimestamp: number };
expect(body.sinceTimestamp).toBe(5000);
}
const calls = fetchSpy.mock.calls.map(c => JSON.parse(c[1]?.body as string) as { contextType?: string });
expect(calls[0]!.contextType).toBeUndefined(); // DM pass (no contextType filter)
expect(calls[1]!.contextType).toBe('friend');
expect(calls[2]!.contextType).toBe('profile');
});
it('advances lastSyncedAt on success', async () => {
const { syncPeerMutationLog } = await import('./federationPeerActivation.js');
testDb.insert(schema.federationPeers).values({
id: 'peer-2', origin: 'https://peer-2.example', hmacSecret: 'secret',
status: 'active', lastSyncedAt: 0, createdAt: Date.now(),
}).run();
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({ events: [], hasMore: false, checkpoint: 1000 }), { status: 200 })
);
const before = Date.now();
await syncPeerMutationLog('peer-2', 'startup_bootstrap');
const after = Date.now();
const row = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-2')).get();
expect(row?.lastSyncedAt).toBeGreaterThanOrEqual(before);
expect(row?.lastSyncedAt).toBeLessThanOrEqual(after);
});
it('does NOT update lastSyncedAt on transient failure', async () => {
const { syncPeerMutationLog } = await import('./federationPeerActivation.js');
testDb.insert(schema.federationPeers).values({
id: 'peer-3', origin: 'https://peer-3.example', hmacSecret: 'secret',
status: 'active', lastSyncedAt: 42_000, createdAt: Date.now(),
}).run();
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response('internal error', { status: 500 })
);
await syncPeerMutationLog('peer-3', 'ensure_peered');
const row = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-3')).get();
expect(row?.lastSyncedAt).toBe(42_000);
});
it('does nothing when peer is not active', async () => {
const { syncPeerMutationLog } = await import('./federationPeerActivation.js');
testDb.insert(schema.federationPeers).values({
id: 'peer-4', origin: 'https://peer-4.example', hmacSecret: 'secret',
status: 'pending', lastSyncedAt: 0, createdAt: Date.now(),
}).run();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
await syncPeerMutationLog('peer-4', 'health_check_recovery');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('advances sinceTimestamp within a pass using data.checkpoint when hasMore is true', async () => {
const { syncPeerMutationLog } = await import('./federationPeerActivation.js');
testDb.insert(schema.federationPeers).values({
id: 'peer-5', origin: 'https://peer-5.example', hmacSecret: 'secret',
status: 'active', lastSyncedAt: 100, createdAt: Date.now(),
}).run();
let call = 0;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
call++;
// First DM call: one event, hasMore=true, checkpoint advances to 2500
// Second DM call: empty, hasMore=false, ends the DM pass
// Remaining calls (friend, profile): empty/done immediately
if (call === 1) {
return new Response(
JSON.stringify({
events: [{ eventType: 'create', messageId: 'm1', timestamp: 200, encryptionVersion: 0 }],
hasMore: true,
checkpoint: 2500,
}),
{ status: 200 },
);
}
return new Response(JSON.stringify({ events: [], hasMore: false, checkpoint: call === 2 ? 2500 : 100 }), { status: 200 });
});
await syncPeerMutationLog('peer-5', 'health_check_recovery');
// Call 1: DM pass, since=100 (peer.lastSyncedAt)
// Call 2: DM pass continuation, since=2500 (advanced by previous checkpoint)
// Call 3: friend pass, since=100 (re-seeded from peer.lastSyncedAt)
// Call 4: profile pass, since=100 (re-seeded from peer.lastSyncedAt)
expect(fetchSpy).toHaveBeenCalledTimes(4);
const bodies = fetchSpy.mock.calls.map(c => JSON.parse(c[1]?.body as string) as { sinceTimestamp: number; contextType?: string });
expect(bodies[0]?.sinceTimestamp).toBe(100);
expect(bodies[0]?.contextType).toBeUndefined();
expect(bodies[1]?.sinceTimestamp).toBe(2500); // advanced by checkpoint from call 1
expect(bodies[1]?.contextType).toBeUndefined();
expect(bodies[2]?.sinceTimestamp).toBe(100); // friend pass re-seeds from peer.lastSyncedAt
expect(bodies[2]?.contextType).toBe('friend');
expect(bodies[3]?.sinceTimestamp).toBe(100); // profile pass re-seeds from peer.lastSyncedAt
expect(bodies[3]?.contextType).toBe('profile');
});
it('skips a poison-pill event, logs it, and advances past it to process subsequent events', async () => {
const { syncPeerMutationLog } = await import('./federationPeerActivation.js');
testDb.insert(schema.federationPeers).values({
id: 'peer-poison', origin: 'https://peer-poison.example', hmacSecret: 'secret',
status: 'active', lastSyncedAt: 100, createdAt: Date.now(),
}).run();
// Return a single batch of 3 events on the DM pass, then empty on friend + profile passes.
let fetchCall = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
fetchCall++;
if (fetchCall === 1) {
return new Response(JSON.stringify({
events: [
{ eventType: 'create', messageId: 'good-1', timestamp: 200, encryptionVersion: 0 },
{ eventType: 'create', messageId: 'poison', timestamp: 300, encryptionVersion: 0 },
{ eventType: 'create', messageId: 'good-2', timestamp: 400, encryptionVersion: 0 },
],
hasMore: false,
checkpoint: 400,
}), { status: 200 });
}
return new Response(JSON.stringify({ events: [], hasMore: false, checkpoint: 100 }), { status: 200 });
});
// Grab the top-level mock and override implementation per-call:
// good-1: resolves, poison: throws, good-2: resolves.
const processMock = vi.mocked(federationRouteMock.processRelayEvents);
processMock.mockClear();
processMock.mockResolvedValueOnce(undefined as never); // good-1
processMock.mockRejectedValueOnce(new Error('simulated processor failure')); // poison
processMock.mockResolvedValueOnce(undefined as never); // good-2
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const before = Date.now();
await syncPeerMutationLog('peer-poison', 'health_check_recovery');
// Verify processRelayEvents was called per-event: 3 calls for the 3 DM events.
expect(processMock).toHaveBeenCalledTimes(3);
// Verify error logged for the poison event.
expect(errorSpy).toHaveBeenCalled();
const errorMessages = errorSpy.mock.calls.map(c => String(c[0] ?? ''));
expect(errorMessages.some(m => m.includes('poison'))).toBe(true);
expect(errorMessages.some(m => m.includes('simulated processor failure'))).toBe(true);
// Verify lastSyncedAt advanced despite the poison event (the critical property).
const row = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-poison')).get();
expect(row?.lastSyncedAt).toBeGreaterThanOrEqual(before);
});
});
describe('onPeerActivated', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
vi.restoreAllMocks();
});
it('runs resetOutboxBackoff and syncPeerMutationLog once, even under concurrent calls', async () => {
const { onPeerActivated } = await import('./federationPeerActivation.js');
testDb.insert(schema.federationPeers).values({
id: 'peer-x', origin: 'https://peer-x.example', hmacSecret: 'secret',
status: 'active', lastSyncedAt: 0, createdAt: Date.now(),
}).run();
let fetchCount = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
fetchCount++;
// Deliberately slow to let the second concurrent call share the in-flight promise.
await new Promise(r => setTimeout(r, 20));
return new Response(JSON.stringify({ events: [], hasMore: false, checkpoint: 0 }), { status: 200 });
});
const p1 = onPeerActivated('peer-x', 'health_check_recovery');
const p2 = onPeerActivated('peer-x', 'accept_new');
await Promise.all([p1, p2]);
// Three fetch calls for the three sync passes (dm, friend, profile) — not six.
expect(fetchCount).toBe(3);
});
it('swallows errors from syncPeerMutationLog so the handler does not throw', async () => {
const { onPeerActivated } = await import('./federationPeerActivation.js');
testDb.insert(schema.federationPeers).values({
id: 'peer-err', origin: 'https://peer-err.example', hmacSecret: 'secret',
status: 'active', lastSyncedAt: 0, createdAt: Date.now(),
}).run();
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
throw new Error('network down');
});
await expect(onPeerActivated('peer-err', 'ensure_peered')).resolves.toBeUndefined();
});
});
describe('onPeerDeactivated', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
vi.clearAllMocks();
});
it('evicts federated calls for the peer origin with peer_transient_failure on network_threshold', async () => {
seedPeer('peer-net', 'unreachable');
testDb.update(schema.federationPeers)
.set({ instanceName: 'NetPeer' })
.where(eq(schema.federationPeers.id, 'peer-net'))
.run();
const { onPeerDeactivated } = await import('./federationPeerActivation.js');
await onPeerDeactivated('peer-net', 'network_threshold');
const { connectionManager } = await import('../ws/handler.js');
expect(connectionManager.evictFederatedCallsForHost).toHaveBeenCalledWith(
'https://peer-net.example',
{ reason: 'peer_transient_failure', peerLabel: 'NetPeer' },
);
});
it('maps rejected status to peer_rejected reason', async () => {
seedPeer('peer-rej', 'rejected');
const { onPeerDeactivated } = await import('./federationPeerActivation.js');
await onPeerDeactivated('peer-rej', 'remote_rejected');
const { connectionManager } = await import('../ws/handler.js');
expect(connectionManager.evictFederatedCallsForHost).toHaveBeenCalledWith(
'https://peer-rej.example',
{ reason: 'peer_rejected', peerLabel: undefined },
);
});
it('maps revoked status to peer_rejected reason', async () => {
seedPeer('peer-rev', 'revoked');
const { onPeerDeactivated } = await import('./federationPeerActivation.js');
await onPeerDeactivated('peer-rev', 'admin_revoked');
const { connectionManager } = await import('../ws/handler.js');
expect(connectionManager.evictFederatedCallsForHost).toHaveBeenCalledWith(
'https://peer-rev.example',
{ reason: 'peer_rejected', peerLabel: undefined },
);
});
it('broadcasts federation_peers_changed to admins', async () => {
seedPeer('peer-broad', 'unreachable');
const { onPeerDeactivated } = await import('./federationPeerActivation.js');
await onPeerDeactivated('peer-broad', 'network_threshold');
const { connectionManager } = await import('../ws/handler.js');
expect(connectionManager.sendToAdmins).toHaveBeenCalledWith({ type: 'federation_peers_changed' });
});
it('aborts silently when the peer row is missing', async () => {
const { onPeerDeactivated } = await import('./federationPeerActivation.js');
await expect(onPeerDeactivated('peer-missing', 'network_threshold')).resolves.toBeUndefined();
const { connectionManager } = await import('../ws/handler.js');
expect(connectionManager.evictFederatedCallsForHost).not.toHaveBeenCalled();
});
it('deduplicates concurrent calls for the same peerId', async () => {
seedPeer('peer-x', 'unreachable');
const { onPeerDeactivated } = await import('./federationPeerActivation.js');
const { connectionManager } = await import('../ws/handler.js');
const p1 = onPeerDeactivated('peer-x', 'network_threshold');
const p2 = onPeerDeactivated('peer-x', 'network_threshold');
await Promise.all([p1, p2]);
// Exactly one eviction call, not two
expect(connectionManager.evictFederatedCallsForHost).toHaveBeenCalledTimes(1);
});
it('uses a dedup map SEPARATE from onPeerActivated', async () => {
seedPeer('peer-flap', 'active');
const { onPeerActivated, onPeerDeactivated } = await import('./federationPeerActivation.js');
// Simulate an activation already in flight — spawn onPeerActivated then
// immediately kick off a deactivation for the same peer id. The latter
// must NOT be swallowed as a dedup hit against the activation.
const actPromise = onPeerActivated('peer-flap', 'ensure_peered');
// Mark peer non-active now — otherwise the deactivation utility's
// own status guard would skip it.
testDb.update(schema.federationPeers)
.set({ status: 'unreachable' })
.where(eq(schema.federationPeers.id, 'peer-flap'))
.run();
const deactPromise = onPeerDeactivated('peer-flap', 'network_threshold');
await Promise.all([actPromise, deactPromise]);
const { connectionManager } = await import('../ws/handler.js');
expect(connectionManager.evictFederatedCallsForHost).toHaveBeenCalled();
});
});