Files
Codeman/test/webhook-notify.test.ts
T
Codeman maintainer 6d6e7da481 fix(notifications): main Save keeps webhook edits, glue test, docs and nits (#523 review)
Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON).

Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now
persists the webhook group beside the settings PUT, the same way it already saves the
model config (saveModelConfigFromSettings), but only when the group differs from what
loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A
refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and
scrolls to the group with the pasted URL still in the box, instead of a success toast.
Send test now saves pending edits first, so it never tests the old URL while the box
shows a new one. The row says so in one line.

Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the
private sendPushNotifications on a real (never started) WebServer with an EMPTY push
store and webhook.json in the instance data dir, delivering through the real
egress-guarded fetch to a local receiver: a permission prompt arrives with the
host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is
deduped, "response complete" is skipped under scope attention and sent under all, and a
disabled config or a non-push event sends nothing. Verified it fails when the webhook
call is moved below the "no subscriptions" return.

Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in
docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public
ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus
a table row, and a line in Settings-Reference; new section 10c in
docs/security-architecture.md for the second outbound channel through the web-tab
egress guard.

Nits:
- Orphaned JSDoc: the webhook schema moved below the push schemas, so
  PushSubscribeSchema has its comment back.
- Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so
  the schema cannot accept a kind the store would coerce away.
- describeError classifies egress refusals with isEgressBlockedError (the
  CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex;
  tests pin a deep cause chain and that matching words alone are not a refusal.
- Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch
  row hints to pick a long random topic on public ntfy.sh.
- Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a
  confirm) sends { url: "", enabled: false }.
- Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status
  moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module).

Browser test extended: main Save persists a pending edit, a refused URL keeps the modal
open with the URL, Send test saves a newly pasted URL first, Remove URL clears it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00

354 lines
14 KiB
TypeScript

// @vitest-environment node
import { createServer, type IncomingMessage, type Server } from 'node:http';
import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { AddressInfo } from 'node:net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildWebhookRequest,
DEFAULT_WEBHOOK_CONFIG,
maskWebhookUrl,
readWebhookConfig,
sendWebhook,
shouldSendWebhook,
webhookConfigPath,
WebhookNotifier,
webhookUrlProblem,
writeWebhookConfig,
type WebhookFetch,
type WebhookMessage,
} from '../src/webhook-notify.js';
import type { WebhookConfig } from '../src/types/push.js';
import { webviewFetch, WebviewEgressBlockedError } from '../src/web/webview-egress.js';
const MSG: WebhookMessage = {
event: 'hook:permission_prompt',
title: 'Permission Required',
body: '[w1-app] Tool: Bash',
urgency: 'critical',
sessionId: 's1',
sessionName: 'w1-app',
host: 'codeman:box',
};
const CFG: WebhookConfig = {
enabled: true,
kind: 'generic',
url: 'https://hooks.example.com/T0/B0/secret',
scope: 'attention',
};
// A 204 (like a 3xx with no body) must be built without one.
const ok = (status = 200) => new Response(status === 204 ? null : '', { status });
describe('webhookUrlProblem', () => {
it.each([
'https://ntfy.sh/mytopic',
'https://hooks.slack.com/services/T/B/x',
'http://localhost:8080/t',
'http://192.168.1.5/hook',
])('accepts %s (loopback and LAN are the point of a local ntfy)', (url) => expect(webhookUrlProblem(url)).toBeNull());
it.each([
['ftp://example.com/x', /http and https/],
['file:///etc/passwd', /http and https/],
['https://user:pw@example.com/x', /credentials/],
['not a url', /valid URL/],
['http://169.254.169.254/latest/meta-data', /link-local|metadata/],
['http://metadata.google.internal/computeMetadata/v1/', /metadata/],
['http://[fd00:ec2::254]/', /metadata|link-local/],
[`https://example.com/${'a'.repeat(2100)}`, /too long/],
])('rejects %s', (url, why) => expect(webhookUrlProblem(url)).toMatch(why));
});
describe('maskWebhookUrl', () => {
it('keeps scheme and host and drops the secret path and query', () => {
const masked = maskWebhookUrl('https://hooks.slack.com/services/T0/B0/XXXXSECRET?token=abc');
expect(masked).toBe('https://hooks.slack.com/•••');
expect(masked).not.toMatch(/SECRET|token|T0/);
});
it('is empty for nothing or garbage', () => {
expect(maskWebhookUrl('')).toBe('');
expect(maskWebhookUrl('nope')).toBe('');
});
});
describe('shouldSendWebhook', () => {
it('needs enabled and a url', () => {
expect(shouldSendWebhook({ ...CFG, enabled: false }, 'critical')).toBe(false);
expect(shouldSendWebhook({ ...CFG, url: '' }, 'critical')).toBe(false);
expect(shouldSendWebhook(CFG, 'critical')).toBe(true);
});
it('scope attention skips "response complete" (info); scope all sends it', () => {
expect(shouldSendWebhook(CFG, 'warning')).toBe(true);
expect(shouldSendWebhook(CFG, 'info')).toBe(false);
expect(shouldSendWebhook({ ...CFG, scope: 'all' }, 'info')).toBe(true);
});
});
describe('buildWebhookRequest', () => {
it('ntfy: plain-text body, priority and tag by urgency, host-prefixed title', () => {
const r = buildWebhookRequest('ntfy', MSG);
expect(r.body).toBe('[w1-app] Tool: Bash');
expect(r.headers.Title).toBe('codeman:box: Permission Required');
expect(r.headers.Priority).toBe('5');
expect(buildWebhookRequest('ntfy', { ...MSG, urgency: 'info' }).headers.Priority).toBe('3');
expect(buildWebhookRequest('ntfy', { ...MSG, urgency: 'warning' }).headers.Priority).toBe('4');
});
it('ntfy: a non-ASCII or multi-line title can never break the header (RFC 2047 encoded)', () => {
const r = buildWebhookRequest('ntfy', { ...MSG, title: 'Prüfung\r\nX-Injected: 1', host: undefined });
expect(r.headers.Title).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
expect(Buffer.from(r.headers.Title.slice(10, -2), 'base64').toString('utf8')).toBe('Prüfung X-Injected: 1');
expect(Object.keys(r.headers)).not.toContain('X-Injected');
});
it('slack: control characters are escaped so agent text cannot ping a channel', () => {
const r = JSON.parse(
buildWebhookRequest('slack', { ...MSG, body: '<!channel> <@U123> <https://evil|click> & more' }).body
);
expect(r.text).not.toMatch(/<[!@h]/);
expect(r.text).toContain('&lt;!channel&gt;');
expect(r.text).toContain('&amp; more');
});
it('discord: mentions are disabled and the content is length-capped', () => {
const r = JSON.parse(buildWebhookRequest('discord', { ...MSG, body: '@everyone ' + 'x'.repeat(5000) }).body);
expect(r.allowed_mentions).toEqual({ parse: [] });
expect(r.content.length).toBeLessThanOrEqual(1900);
});
it('generic: structured JSON with the session and a timestamp', () => {
const r = JSON.parse(buildWebhookRequest('generic', MSG, new Date('2026-10-02T12:00:00Z')).body);
expect(r).toEqual({
event: 'hook:permission_prompt',
title: 'Permission Required',
body: '[w1-app] Tool: Bash',
urgency: 'critical',
sessionId: 's1',
sessionName: 'w1-app',
host: 'codeman:box',
at: '2026-10-02T12:00:00.000Z',
});
});
it('truncates a long body for every kind', () => {
const long = 'y'.repeat(2000);
expect(buildWebhookRequest('ntfy', { ...MSG, body: long }).body.length).toBeLessThanOrEqual(500);
expect(JSON.parse(buildWebhookRequest('generic', { ...MSG, body: long }).body).body.length).toBeLessThanOrEqual(
500
);
});
});
describe('store', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'webhook-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('returns the defaults for a missing or corrupt file', async () => {
expect(await readWebhookConfig(dir)).toEqual(DEFAULT_WEBHOOK_CONFIG);
writeFileSync(webhookConfigPath(dir), '{ nope');
expect(await readWebhookConfig(dir)).toEqual(DEFAULT_WEBHOOK_CONFIG);
});
it('round-trips and writes the file readable by its owner only', async () => {
await writeWebhookConfig(dir, CFG);
expect(await readWebhookConfig(dir)).toEqual(CFG);
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
});
it('tightens an existing world-readable file instead of keeping its mode', async () => {
writeFileSync(webhookConfigPath(dir), '{}', { mode: 0o644 });
await writeWebhookConfig(dir, CFG);
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
});
it('coerces unknown kinds and scopes back to the defaults', async () => {
writeFileSync(
webhookConfigPath(dir),
JSON.stringify({ enabled: true, kind: 'telegram', scope: 'nope', url: 'https://x.test/h' })
);
const cfg = await readWebhookConfig(dir);
expect(cfg).toEqual({ enabled: true, kind: 'ntfy', scope: 'attention', url: 'https://x.test/h' });
});
});
describe('sendWebhook', () => {
it('posts the built request with redirects off and a timeout signal', async () => {
const fetchImpl = vi.fn<WebhookFetch>(async () => ok(204));
const r = await sendWebhook(CFG, MSG, fetchImpl);
expect(r).toMatchObject({ ok: true, status: 204 });
const [target, init] = fetchImpl.mock.calls[0];
expect(target.href).toBe(CFG.url);
expect(init.method).toBe('POST');
expect(init.redirect).toBe('manual');
expect(init.signal).toBeInstanceOf(AbortSignal);
});
it('reports an HTTP failure by status, a redirect as such, and never echoes the URL', async () => {
const bad = await sendWebhook(CFG, MSG, async () => ok(404));
expect(bad).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
const redirect = await sendWebhook(CFG, MSG, async () => ok(302));
expect(redirect.ok).toBe(false);
expect(redirect.error).toMatch(/redirects/);
for (const r of [bad, redirect]) expect(JSON.stringify(r)).not.toContain('secret');
});
it('turns network errors into short messages that do not contain the URL', async () => {
const cases: [unknown, RegExp][] = [
[Object.assign(new Error('x'), { name: 'TimeoutError' }), /Timed out/],
[Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }), /Host not found/],
[Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }), /refused/],
[new TypeError('fetch failed https://hooks.example.com/T0/B0/secret'), /Network error/],
];
for (const [err, re] of cases) {
const r = await sendWebhook(CFG, MSG, async () => {
throw err;
});
expect(r.ok).toBe(false);
expect(r.error).toMatch(re);
expect(JSON.stringify(r)).not.toContain('secret');
}
});
it('refuses a blocked URL without fetching', async () => {
const fetchImpl = vi.fn<WebhookFetch>(async () => ok());
const r = await sendWebhook({ ...CFG, url: 'http://169.254.169.254/latest' }, MSG, fetchImpl);
expect(r.ok).toBe(false);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('recognises an egress refusal by its code anywhere in the cause chain, not by message text', async () => {
// A DNS name resolving into a blocked range is refused by the connect-time lookup, and undici
// hands that back wrapped (`fetch failed` -> connect error -> the refusal), so it can sit deep.
const blocked = new WebviewEgressBlockedError('cloud metadata');
const deep = new TypeError('fetch failed', { cause: new Error('connect failed', { cause: blocked }) });
for (const err of [blocked, deep]) {
const r = await sendWebhook(CFG, MSG, async () => {
throw err;
});
expect(r.error).toBe('Refused: target is a link-local or cloud-metadata address');
}
// Words alone (an upstream error that happens to mention them) are not a refusal.
const r = await sendWebhook(CFG, MSG, async () => {
throw new TypeError('fetch failed', { cause: new Error('link-local EGRESS hiccup') });
});
expect(r.error).toBe('Network error');
});
});
describe('delivery through the real egress-guarded fetch', () => {
let server: Server;
let received: { headers: IncomingMessage['headers']; body: string } | null;
let port: number;
beforeEach(async () => {
received = null;
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received = { headers: req.headers, body };
res.statusCode = req.url === '/redirect' ? 302 : 200;
if (req.url === '/redirect') res.setHeader('Location', 'http://169.254.169.254/');
res.end('ok');
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
});
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
it('delivers to a local server (loopback is allowed) with the ntfy headers', async () => {
const r = await sendWebhook({ kind: 'ntfy', url: `http://127.0.0.1:${port}/topic` }, MSG, webviewFetch);
expect(r).toMatchObject({ ok: true, status: 200 });
expect(received?.body).toBe('[w1-app] Tool: Bash');
expect(received?.headers.priority).toBe('5');
});
it('does not follow a redirect to a metadata address', async () => {
const r = await sendWebhook({ kind: 'generic', url: `http://127.0.0.1:${port}/redirect` }, MSG, webviewFetch);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/redirects/);
});
it('refuses a metadata address at the fetch layer too', async () => {
await expect(webviewFetch(new URL('http://169.254.169.254/'))).rejects.toThrow();
});
});
describe('WebhookNotifier', () => {
const make = (cfg: Partial<WebhookConfig> = {}, fetchImpl: WebhookFetch = async () => ok(), now?: () => number) => {
const sent = vi.fn(fetchImpl);
const notifier = new WebhookNotifier(async () => ({ ...CFG, ...cfg }), sent, now);
return { notifier, sent };
};
it('sends an event that needs attention and records the result', async () => {
const { notifier, sent } = make();
await notifier.notify(MSG);
expect(sent).toHaveBeenCalledTimes(1);
expect(notifier.lastResult).toMatchObject({ ok: true });
});
it('sends nothing when disabled, without a url, or for info under scope attention', async () => {
for (const cfg of [{ enabled: false }, { url: '' }]) {
const { notifier, sent } = make(cfg);
await notifier.notify(MSG);
expect(sent).not.toHaveBeenCalled();
}
const { notifier, sent } = make();
await notifier.notify({ ...MSG, urgency: 'info' });
expect(sent).not.toHaveBeenCalled();
});
it('sends the same event for the same session once per window, then again', async () => {
let t = 1_000_000;
const { notifier, sent } = make(
{},
async () => ok(),
() => t
);
await notifier.notify(MSG);
t += 1000;
await notifier.notify(MSG);
expect(sent).toHaveBeenCalledTimes(1);
await notifier.notify({ ...MSG, sessionId: 's2' });
expect(sent).toHaveBeenCalledTimes(2);
t += 5000;
await notifier.notify(MSG);
expect(sent).toHaveBeenCalledTimes(3);
});
it('caps what is in flight so a hung endpoint cannot pile up requests', async () => {
let release: () => void = () => undefined;
const gate = new Promise<void>((r) => (release = r));
const { notifier, sent } = make({}, async () => (await gate, ok()));
const pending = Array.from({ length: 12 }, (_, i) => notifier.notify({ ...MSG, sessionId: `s${i}` }));
await new Promise((r) => setTimeout(r, 20));
expect(sent).toHaveBeenCalledTimes(5);
release();
await Promise.all(pending);
});
it('a test send ignores enabled and scope, bypasses dedupe, and records the result', async () => {
const { notifier, sent } = make({ enabled: false });
const r1 = await notifier.sendTest(CFG, 'codeman:box');
const r2 = await notifier.sendTest(CFG);
expect(r1.ok && r2.ok).toBe(true);
expect(sent).toHaveBeenCalledTimes(2);
expect(notifier.lastResult).toBe(r2);
expect(JSON.parse(sent.mock.calls[0][1].body as string).host).toBe('codeman:box');
});
it('never throws on a failing endpoint', async () => {
const { notifier } = make({}, async () => {
throw new Error('boom');
});
await expect(notifier.notify(MSG)).resolves.toBeUndefined();
expect(notifier.lastResult).toMatchObject({ ok: false });
});
});