Files
technitium-console/hub/webhook.test.ts
T
DenozordecandClaude Opus 5.5 9742f0fe69
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s
Hub stage 8: settings drift, webhook notices, profiles
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>
2026-11-01 02:11:19 +07:00

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([])
})
})