/** * Fan-out + subscriber-store integration tests. * * Uses real localhost HTTP servers (Bun.serve) as subscriber stand- * ins, not verdaccio — these tests exercise the webhook flow itself * (signing, retry, parallel fan-out), not anything package-publish- * specific. Each test spins up its own server on a random port and * tears it down in afterEach. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { PublishEvent, Subscriber, WebhookEnvelope } from '@celilo/event-bus/build-bus'; import { verifyEvent } from '@celilo/event-bus/build-bus'; import { fanOut } from './fan-out'; import { addSubscriber, loadSubscribers, removeSubscriberByUrl, subscriberStorePath, } from './subscriber-store'; function buildEvent(overrides: Partial = {}): PublishEvent { return { eventId: '11111111-1111-4111-8111-111111111111', timestamp: new Date().toISOString(), registry: 'npm', tag: 'latest', package: { name: '@celilo/cli', version: '0.4.0' }, ...overrides, }; } interface TestServer { url: string; /** Latest envelope POST'd to this server. */ received: WebhookEnvelope[]; /** Latest x-celilo-signature header POSTed. */ signatures: string[]; stop(): void; } /** * Spin up a tiny HTTP server with configurable response behavior. * Returns its URL so the caller can register it as a subscriber. */ function startServer( respond: (envelope: WebhookEnvelope, callCount: number) => Response, ): TestServer { const received: WebhookEnvelope[] = []; const signatures: string[] = []; let callCount = 0; const server = Bun.serve({ port: 0, fetch: async (req) => { callCount++; const body = await req.json(); received.push(body as WebhookEnvelope); signatures.push(req.headers.get('x-celilo-signature') ?? ''); return respond(body as WebhookEnvelope, callCount); }, }); return { url: `http://localhost:${server.port}/`, received, signatures, stop: () => server.stop(true), }; } describe('fanOut', () => { test('delivers a signed envelope to a single subscriber on 200', async () => { const server = startServer(() => new Response('ok', { status: 200 })); try { const subscriber: Subscriber = { name: 'test-sub', url: server.url, secret: 'shared-secret', match: {}, }; const event = buildEvent(); const results = await fanOut(event, [subscriber]); expect(results).toHaveLength(1); expect(results[0].ok).toBe(true); expect(results[0].status).toBe(200); expect(results[0].attempts).toBe(1); // The receiver saw the envelope and the signature header. expect(server.received).toHaveLength(1); expect(server.received[0].event.eventId).toBe(event.eventId); expect(server.signatures[0]).toMatch(/^sha256=[0-9a-f]{64}$/); // The signature actually verifies against the secret. expect(verifyEvent(server.received[0].event, server.signatures[0], 'shared-secret')).toBe( true, ); // ...and doesn't verify against a different secret. expect(verifyEvent(server.received[0].event, server.signatures[0], 'wrong')).toBe(false); } finally { server.stop(); } }); test('skips subscribers whose match rule does not fire', async () => { const server = startServer(() => new Response('ok')); try { const event = buildEvent({ registry: 'npm', tag: 'latest' }); const results = await fanOut(event, [ { url: server.url, secret: 'k', match: { tag: 'alpha' }, // doesn't match event.tag=latest }, ]); expect(results).toHaveLength(0); expect(server.received).toHaveLength(0); } finally { server.stop(); } }); test('retries on 5xx and eventually succeeds', async () => { const server = startServer((_e, n) => n < 3 ? new Response('boom', { status: 503 }) : new Response('ok'), ); try { const results = await fanOut(buildEvent(), [{ url: server.url, secret: 'k', match: {} }], { maxAttempts: 5, baseBackoffMs: 1, }); expect(results[0].ok).toBe(true); expect(results[0].attempts).toBe(3); } finally { server.stop(); } }); test('does NOT retry on 4xx (client error is permanent)', async () => { const server = startServer(() => new Response('bad request', { status: 400 })); try { const results = await fanOut(buildEvent(), [{ url: server.url, secret: 'k', match: {} }], { maxAttempts: 5, baseBackoffMs: 1, }); expect(results[0].ok).toBe(false); expect(results[0].status).toBe(400); expect(results[0].attempts).toBe(1); expect(results[0].error).toContain('no retry on 4xx'); } finally { server.stop(); } }); test('gives up after maxAttempts of 5xx', async () => { const server = startServer(() => new Response('boom', { status: 503 })); try { const results = await fanOut(buildEvent(), [{ url: server.url, secret: 'k', match: {} }], { maxAttempts: 3, baseBackoffMs: 1, }); expect(results[0].ok).toBe(false); expect(results[0].status).toBe(503); expect(results[0].attempts).toBe(3); } finally { server.stop(); } }); test('fans out to multiple subscribers in parallel', async () => { const serverA = startServer(() => new Response('ok')); const serverB = startServer(() => new Response('ok')); try { const event = buildEvent(); const results = await fanOut(event, [ { url: serverA.url, secret: 'a', match: {} }, { url: serverB.url, secret: 'b', match: {} }, ]); expect(results).toHaveLength(2); expect(results.every((r) => r.ok)).toBe(true); expect(serverA.received).toHaveLength(1); expect(serverB.received).toHaveLength(1); // Each subscriber got its OWN signature (per-target secret). expect(serverA.signatures[0]).not.toBe(serverB.signatures[0]); expect(verifyEvent(serverA.received[0].event, serverA.signatures[0], 'a')).toBe(true); expect(verifyEvent(serverB.received[0].event, serverB.signatures[0], 'b')).toBe(true); } finally { serverA.stop(); serverB.stop(); } }); test('records network failure as ok=false', async () => { const results = await fanOut( buildEvent(), [{ url: 'http://127.0.0.1:1/no-such-port', secret: 'k', match: {} }], { maxAttempts: 1, timeoutMs: 200, }, ); expect(results[0].ok).toBe(false); expect(results[0].error).toBeDefined(); }); }); describe('subscriber-store', () => { let storeDir: string; let originalEnv: string | undefined; beforeEach(() => { storeDir = mkdtempSync(join(tmpdir(), 'celilo-build-bus-store-')); originalEnv = process.env.CELILO_BUILD_BUS_SUBSCRIBERS_PATH; process.env.CELILO_BUILD_BUS_SUBSCRIBERS_PATH = join(storeDir, 'subscribers.json'); }); afterEach(() => { process.env.CELILO_BUILD_BUS_SUBSCRIBERS_PATH = originalEnv; rmSync(storeDir, { recursive: true, force: true }); }); test('subscriberStorePath honors the env override', () => { expect(subscriberStorePath()).toBe(join(storeDir, 'subscribers.json')); }); test('loadSubscribers returns [] when the file is missing', () => { expect(loadSubscribers()).toEqual([]); }); test('round-trip: add → load → remove', () => { const sub: Subscriber = { name: 'lunacycle', url: 'https://lunacycle.lab/build-bus', secret: 'hex-secret-here', match: { registry: 'npm', packagePattern: '@celilo/*' }, }; const addResult = addSubscriber(sub); expect(addResult.replaced).toBeUndefined(); const after = loadSubscribers(); expect(after).toHaveLength(1); expect(after[0]).toEqual(sub); const removed = removeSubscriberByUrl(sub.url); expect(removed).toEqual(sub); expect(loadSubscribers()).toEqual([]); }); test('adding a subscriber with an existing URL replaces it (idempotent rotation)', () => { const original: Subscriber = { url: 'https://x.test/', secret: 'old', match: { registry: 'npm' }, }; addSubscriber(original); const rotated: Subscriber = { url: 'https://x.test/', secret: 'new', match: { registry: 'celilo-registry' }, }; const result = addSubscriber(rotated); expect(result.replaced).toEqual(original); const after = loadSubscribers(); expect(after).toHaveLength(1); expect(after[0].secret).toBe('new'); expect(after[0].match.registry).toBe('celilo-registry'); }); test('removeSubscriberByUrl returns undefined when URL not registered', () => { expect(removeSubscriberByUrl('https://not-here.test/')).toBeUndefined(); }); });