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.
455 lines
19 KiB
TypeScript
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();
|
|
});
|
|
});
|