// @vitest-environment node /* Stage 8, notices to the outside: the signature, the timeout and the single retry, what is logged, and the two endpoints (status and test) on a hub that has a webhook. */ import { createHmac } from 'node:crypto' import http from 'node:http' import type { AddressInfo } from 'node:net' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { parseConfig } from './config.ts' import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts' import { Webhook, sign } from './webhook.ts' const urlConfig = (webhookUrl: string | null, webhookSecret: string | null = null) => ({ webhookUrl, webhookSecret, domain: 'hub.example' }) describe('Webhook', () => { afterEach(() => vi.restoreAllMocks()) it('posts {event, at, hub, items} and signs the exact body when there is a secret', async () => { const seen: { url: string; init: RequestInit }[] = [] const hook = new Webhook(urlConfig('https://hooks.example/in?token=abc', 's3cret'), { now: () => Date.parse('2026-11-01T00:00:00Z'), fetch: (async (url: string, init: RequestInit) => { seen.push({ url, init }) return new Response('ok') }) as typeof fetch, }) hook.send('drift.found', [{ kind: 'settings', server: 'a', fields: ['blockingType'] }]) await hook.settled() expect(seen).toHaveLength(1) const body = String(seen[0].init.body) expect(JSON.parse(body)).toEqual({ event: 'drift.found', at: '2026-11-01T00:00:00.000Z', hub: 'hub.example', items: [{ kind: 'settings', server: 'a', fields: ['blockingType'] }] }) const headers = seen[0].init.headers as Record expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 's3cret').update(body).digest('hex')}`) expect(headers['x-hub-signature']).toBe(sign('s3cret', body)) }) it('sends no signature without a secret and nothing at all without a URL', async () => { const seen: RequestInit[] = [] const fetcher = (async (_u: string, init: RequestInit) => { seen.push(init) return new Response('ok') }) as typeof fetch const hook = new Webhook(urlConfig('https://hooks.example/in'), { fetch: fetcher }) hook.send('drift.cleared', [{ kind: 'app' }]) await hook.settled() expect((seen[0].headers as Record)['x-hub-signature']).toBeUndefined() const none = new Webhook(urlConfig(null), { fetch: fetcher }) none.send('drift.found', [{ kind: 'app' }]) await none.settled() expect(seen).toHaveLength(1) expect(none.status()).toEqual({ configured: false, host: null, signed: false, last: null }) }) it('retries once and logs the host only, never the query string or the error text', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => undefined) const out = vi.spyOn(console, 'log').mockImplementation(() => undefined) let tries = 0 const hook = new Webhook(urlConfig('https://hooks.example:8443/in?token=TOPSECRET'), { retryDelayMs: 0, fetch: (async () => { tries++ throw new TypeError('fetch failed https://hooks.example:8443/in?token=TOPSECRET', { cause: { code: 'ECONNREFUSED' } }) }) as typeof fetch, }) hook.send('drift.found', [{ kind: 'app' }]) await hook.settled() expect(tries).toBe(2) const text = [...log.mock.calls, ...out.mock.calls].flat().join(' ') expect(text).toContain('hooks.example:8443') expect(text).toContain('ECONNREFUSED') expect(text).not.toContain('TOPSECRET') expect(text).not.toContain('token=') expect(hook.status()).toMatchObject({ configured: true, host: 'hooks.example:8443', last: { event: 'drift.found', ok: false, error: 'ECONNREFUSED' } }) expect(JSON.stringify(hook.status())).not.toContain('TOPSECRET') }) it('treats a non-2xx answer as a failure and stops after the one retry; a second success is ok', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined) const answers = [500, 204] let tries = 0 const hook = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: answers[tries++] ?? 500 })) as typeof fetch }) hook.send('drift.found', [{ kind: 'app' }]) await hook.settled() expect(tries).toBe(2) expect(hook.status().last).toMatchObject({ ok: true }) const bad = new Webhook(urlConfig('https://hooks.example/in'), { retryDelayMs: 0, fetch: (async () => new Response(null, { status: 503 })) as typeof fetch }) expect(await bad.deliver('webhook.test', [{}])).toMatchObject({ ok: false, error: 'HTTP 503' }) }) it('does not wait for a hanging receiver when sending, and gives up after the timeout', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined) const hook = new Webhook(urlConfig('https://hooks.example/in'), { timeoutMs: 40, retryDelayMs: 0, fetch: ((_u: string, init: RequestInit) => new Promise((_resolve, reject) => { init.signal?.addEventListener('abort', () => reject((init.signal as AbortSignal).reason)) })) as typeof fetch, }) const started = Date.now() hook.send('drift.found', [{ kind: 'app' }]) expect(Date.now() - started).toBeLessThan(30) await hook.settled() expect(hook.status().last).toMatchObject({ ok: false, error: 'TimeoutError' }) }) it('collects failed writes of one moment into a single notice', async () => { const bodies: { event: string; items: unknown[] }[] = [] const hook = new Webhook(urlConfig('https://hooks.example/in'), { batchMs: 5, fetch: (async (_u: string, init: RequestInit) => { bodies.push(JSON.parse(String(init.body))) return new Response('ok') }) as typeof fetch, }) hook.queueFailed({ action: 'a', server: 'x' }) hook.queueFailed({ action: 'a', server: 'y' }) await hook.settled() expect(bodies).toHaveLength(1) expect(bodies[0]).toMatchObject({ event: 'write.failed', items: [{ server: 'x' }, { server: 'y' }] }) }) }) describe('HUB_WEBHOOK_URL', () => { const base = { HUB_SERVERS: 'a=http://a.example', A_TOKEN: 't', HUB_USER: 'u', HUB_PASSWORD: 'p' } it('accepts http and https and refuses anything else', () => { expect(parseConfig({ ...base, HUB_WEBHOOK_URL: 'https://h.example/x?y=1', HUB_WEBHOOK_SECRET: 's' })).toMatchObject({ webhookUrl: 'https://h.example/x?y=1', webhookSecret: 's' }) expect(parseConfig(base)).toMatchObject({ webhookUrl: null, webhookSecret: null }) expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'ftp://h.example' })).toThrow(/HUB_WEBHOOK_URL/) expect(() => parseConfig({ ...base, HUB_WEBHOOK_URL: 'h.example' })).toThrow(/HUB_WEBHOOK_URL/) }) }) describe('the webhook on a hub', () => { let a: Fake let b: Fake let hub: TestHub let receiver: http.Server let received: { headers: http.IncomingHttpHeaders; body: string }[] let status = 200 beforeAll(async () => { a = await fake() b = await fake() receiver = http.createServer((req, res) => { const chunks: Buffer[] = [] req.on('data', (c: Buffer) => chunks.push(c)) req.on('end', () => { received.push({ headers: req.headers, body: Buffer.concat(chunks).toString('utf8') }) res.writeHead(status).end('ok') }) }) await new Promise((r) => receiver.listen(0, '127.0.0.1', r)) hub = await startHub(a, b, { HUB_WEBHOOK_URL: `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/in?token=QUERYSECRET`, HUB_WEBHOOK_SECRET: 'shared-secret', }) }) afterAll(() => { hub.close() a.server.close() b.server.close() receiver.close() }) beforeEach(() => { received = [] status = 200 }) it('shows its status to an admin only, with the host and never the query', async () => { for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/status', {}, 'GET')).code).toBe(403) const got = await hub.call('admin', 'hub/webhook/status', {}, 'GET') expect(got.code).toBe(200) expect(got.body.response).toMatchObject({ configured: true, signed: true, last: null }) expect(got.body.response.host).toMatch(/^127\.0\.0\.1:\d+$/) expect(JSON.stringify(got.body)).not.toContain('QUERYSECRET') }) it('refuses the test to every role but admin, and refuses GET', async () => { for (const role of ['viewer', 'operator', 'apps-admin']) expect((await hub.call(role, 'hub/webhook/test')).code).toBe(403) expect((await hub.call('admin', 'hub/webhook/test', {}, 'GET')).code).toBe(405) expect(received).toEqual([]) }) it('sends a signed test notice, records the result and audits it', async () => { const done = await hub.call('admin', 'hub/webhook/test') expect(done.code).toBe(200) expect(done.body.response.last).toMatchObject({ event: 'webhook.test', ok: true }) expect(received).toHaveLength(1) const { headers, body } = received[0] expect(headers['x-hub-signature']).toBe(`sha256=${createHmac('sha256', 'shared-secret').update(body).digest('hex')}`) expect(JSON.parse(body)).toMatchObject({ event: 'webhook.test', hub: 'hub' }) const audit = await hub.audit() expect(audit.find((e) => e.action === 'webhook.test')).toMatchObject({ user: 'admin', outcome: 'ok' }) expect(JSON.stringify(audit)).not.toContain('QUERYSECRET') expect(JSON.stringify(audit)).not.toContain('shared-secret') }) it('reports a failing receiver in the answer without failing the request', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined) status = 500 const done = await hub.call('admin', 'hub/webhook/test') expect(done.code).toBe(200) expect(done.body.status).toBe('ok') expect(done.body.response.last).toMatchObject({ ok: false, error: 'HTTP 500' }) expect(received).toHaveLength(2) expect((await hub.audit()).find((e) => e.action === 'webhook.test' && e.outcome === 'failed')).toBeTruthy() vi.restoreAllMocks() }) it('sends write.failed for a failed audited write, without the values', async () => { a.on('zones/records/add', () => ({ error: 'The record already exists.' })) const done = await hub.call('admin', 'zones/records/add', { node: 'a', zone: 'example.com', domain: 'www.example.com', type: 'TXT', text: 'TOPVALUE' }) expect(done.body.status).toBe('error') for (let i = 0; i < 40 && received.length === 0; i++) await new Promise((r) => setTimeout(r, 50)) expect(received).toHaveLength(1) const sent = JSON.parse(received[0].body) expect(sent.event).toBe('write.failed') expect(sent.items[0]).toMatchObject({ action: 'zones.records.add', server: 'a', user: 'admin' }) expect(received[0].body).not.toContain('TOPVALUE') // A failed login is not a write. received = [] await fetch(`${hub.base}/api/user/login`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'user=admin&pass=wrong' }) await new Promise((r) => setTimeout(r, 500)) expect(received).toEqual([]) }) })