Files
Codeman/test/webhook-push-glue.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

128 lines
5.5 KiB
TypeScript

/**
* @fileoverview The glue between `WebServer.sendPushNotifications()` and the webhook channel.
*
* The notifier and the routes are tested on their own (webhook-notify.test.ts,
* routes/webhook-routes.test.ts); this file pins the one line that makes the feature exist: the
* webhook fires even when there is NO Web Push subscription, which is exactly the headless server
* it was built for. Moving the call below the "no subscriptions" early return passes every other
* test and silently kills the feature, so it is asserted here end to end: a real WebServer (never
* started), an empty push store, `webhook.json` in the instance data dir, and a local HTTP receiver
* reached through the real egress-guarded fetch.
*
* Port: N/A (no server start; the receiver binds an ephemeral 127.0.0.1 port).
*/
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
vi.mock('web-push', () => ({
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
}));
import { WebServer } from '../src/web/server.js';
import { getDataDir } from '../src/config/instance.js';
import { writeWebhookConfig, type WebhookMessage, type WebhookNotifier } from '../src/webhook-notify.js';
import type { WebhookConfig } from '../src/types/push.js';
interface Received {
url?: string;
title?: string;
body: string;
}
type PushSender = { sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void> };
let receiver: Server;
let receiverUrl: string;
const got: Received[] = [];
beforeAll(async () => {
receiver = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
// Recorded before the response goes out, so a resolved notify() means it is already here.
got.push({ url: req.url, title: req.headers.title as string | undefined, body });
res.end('ok');
});
});
await new Promise<void>((resolve) => receiver.listen(0, '127.0.0.1', resolve));
receiverUrl = `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/codeman-topic`;
});
afterAll(() => new Promise<void>((resolve) => receiver.close(() => resolve())));
beforeEach(() => {
got.length = 0;
sendNotification.mockClear();
});
/**
* A headless server: no push subscription at all. The real notifier stays in place; its `notify`
* is only wrapped to collect the promises `sendPushNotifications` fires and forgets, so a test can
* await delivery instead of sleeping.
*/
async function headlessServer(cfg: Partial<WebhookConfig>) {
await writeWebhookConfig(getDataDir(), { enabled: true, kind: 'ntfy', scope: 'attention', url: receiverUrl, ...cfg });
const server = new WebServer(0, false, true, '127.0.0.1', 'box');
(server as unknown as { pushStore: { getAll: () => never[] } }).pushStore = { getAll: () => [] };
const notifier = (server as unknown as { webhookNotifier: WebhookNotifier }).webhookNotifier;
const pending: Promise<void>[] = [];
const realNotify = notifier.notify.bind(notifier);
notifier.notify = (msg: WebhookMessage) => {
const p = realNotify(msg);
pending.push(p);
return p;
};
const send = async (event: string, data: Record<string, unknown>) => {
await (server as unknown as PushSender).sendPushNotifications(event, data);
await Promise.all(pending.splice(0));
};
return { send };
}
describe('sendPushNotifications -> webhook (the headless case)', () => {
it('delivers a permission prompt to the webhook with zero push subscriptions', async () => {
const { send } = await headlessServer({});
await send('hook:permission_prompt', { sessionId: 's1', sessionName: 'w1-app', tool_name: 'Bash' });
expect(got).toHaveLength(1);
expect(got[0]).toEqual({
url: '/codeman-topic',
title: 'codeman:box: Permission Required',
body: '[w1-app] Tool: Bash',
});
// Web Push had nobody to send to, and the webhook did not need it.
expect(sendNotification).not.toHaveBeenCalled();
});
it('dedupes an immediate repeat of the same event for the same session', async () => {
const { send } = await headlessServer({});
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
expect(got).toHaveLength(1);
await send('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2-app' });
expect(got).toHaveLength(2);
});
it('skips "response complete" under scope attention and sends it under scope all', async () => {
const attention = await headlessServer({ scope: 'attention' });
await attention.send('hook:stop', { sessionId: 's1', sessionName: 'w1-app' });
expect(got).toHaveLength(0);
const all = await headlessServer({ scope: 'all' });
await all.send('hook:stop', { sessionId: 's1', sessionName: 'w1-app' });
expect(got).toHaveLength(1);
expect(got[0].title).toBe('codeman:box: Response Complete');
});
it('sends nothing while disabled, and nothing for an event Web Push does not carry', async () => {
const disabled = await headlessServer({ enabled: false });
await disabled.send('hook:permission_prompt', { sessionId: 's1', sessionName: 'w1-app', tool_name: 'Bash' });
const enabled = await headlessServer({});
await enabled.send('session:created', { sessionId: 's1', sessionName: 'w1-app' });
expect(got).toHaveLength(0);
});
});