The drift check now also covers the settings baseline and the Blocked and Allowed lists baseline; the drift dialog offers Open compare, Accept as baseline and Revert to baseline for them. Old drift.json files still load. HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC signature) receives drift.found, drift.cleared and write.failed notices, only on change, without values, never blocking a request and never logging the address beyond its host. Admins see the webhook status and can send a test from About and from the drift dialog. Profiles bundle the hub's app, settings and lists baselines. Applying one to a server runs install, app configs, settings and lists in order after a preview; a failed step stops that server's later steps and Retry failed resumes from it. Profiles export and import as a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
// @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<string, string>
|
|
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<string, string>)['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<void>((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([])
|
|
})
|
|
})
|