mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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>
166 lines
7.8 KiB
TypeScript
166 lines
7.8 KiB
TypeScript
/** @fileoverview Settings → Notifications → Webhook, end to end: real server, real Chromium, a local receiver. */
|
||
import { createServer, type Server } from 'node:http';
|
||
import type { AddressInfo } from 'node:net';
|
||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||
import { chromium, type Browser, type Page } from 'playwright';
|
||
import { WebServer } from '../src/web/server.js';
|
||
|
||
const PORT = 3195;
|
||
const SECRET = 'SUPERSECRET-topic-123';
|
||
|
||
describe('Webhook settings in a real browser', () => {
|
||
let server: WebServer;
|
||
let browser: Browser;
|
||
let page: Page;
|
||
let receiver: Server;
|
||
let receiverPort: number;
|
||
let respondWith = 200;
|
||
const got: { url?: string; title?: string; body: string }[] = [];
|
||
|
||
beforeAll(async () => {
|
||
receiver = createServer((req, res) => {
|
||
let body = '';
|
||
req.on('data', (c) => (body += c));
|
||
req.on('end', () => {
|
||
got.push({ url: req.url, title: req.headers.title as string | undefined, body });
|
||
res.statusCode = respondWith;
|
||
res.end('x');
|
||
});
|
||
});
|
||
await new Promise<void>((r) => receiver.listen(0, '127.0.0.1', r));
|
||
receiverPort = (receiver.address() as AddressInfo).port;
|
||
|
||
server = new WebServer(PORT, false, true);
|
||
await server.start();
|
||
browser = await chromium.launch({ headless: true });
|
||
page = await browser.newPage();
|
||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||
await page.waitForSelector('#webhookGroup', { state: 'attached' });
|
||
await page.waitForFunction(() => document.getElementById('webhookGroup')!.style.display !== 'none');
|
||
}, 90000);
|
||
|
||
afterAll(async () => {
|
||
if (browser) await browser.close();
|
||
if (server) await server.stop();
|
||
await new Promise<void>((r) => receiver.close(() => r()));
|
||
}, 60000);
|
||
|
||
const result = () => page.textContent('#webhookResult');
|
||
|
||
// The checkbox sits behind a styled slider, so click the switch like a user does.
|
||
const setSwitch = async (on: boolean) => {
|
||
if ((await page.isChecked('#webhookEnabled')) !== on) await page.click('label.switch:has(#webhookEnabled)');
|
||
expect(await page.isChecked('#webhookEnabled')).toBe(on);
|
||
};
|
||
|
||
it('shows the group, starts empty, and refuses to enable without a URL', async () => {
|
||
expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.');
|
||
await setSwitch(true);
|
||
await page.click('#webhookSaveBtn');
|
||
await page.waitForFunction(() =>
|
||
/Add a webhook URL/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||
);
|
||
await setSwitch(false);
|
||
});
|
||
|
||
it('refuses a cloud-metadata URL with the server’s reason', async () => {
|
||
await page.fill('#webhookUrl', 'http://169.254.169.254/latest');
|
||
await page.click('#webhookSaveBtn');
|
||
await page.waitForFunction(() =>
|
||
/metadata|link-local/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||
);
|
||
});
|
||
|
||
it('saves a URL, shows only scheme and host, and empties the secret field', async () => {
|
||
await page.selectOption('#webhookKind', 'ntfy');
|
||
await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/${SECRET}`);
|
||
await setSwitch(true);
|
||
await page.click('#webhookSaveBtn');
|
||
await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||
expect(await page.textContent('#webhookUrlHint')).toBe(`Saved: http://127.0.0.1:${receiverPort}/•••`);
|
||
expect(await page.inputValue('#webhookUrl')).toBe('');
|
||
expect(await page.content()).not.toContain(SECRET);
|
||
// ...and GET /api/webhook never returns it either.
|
||
const body = await page.evaluate(async () => (await fetch('/api/webhook')).text());
|
||
expect(body).not.toContain('SUPERSECRET');
|
||
});
|
||
|
||
it('sends a test message that reaches the receiver with the ntfy headers', async () => {
|
||
got.length = 0;
|
||
await page.click('#webhookTestBtn');
|
||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0].url).toBe(`/${SECRET}`);
|
||
expect(got[0].title).toMatch(/Codeman test notification/);
|
||
expect(got[0].body).toMatch(/webhook notifications are working/);
|
||
});
|
||
|
||
it('reports a failing endpoint without exposing the URL', async () => {
|
||
respondWith = 500;
|
||
await page.click('#webhookTestBtn');
|
||
await page.waitForFunction(() =>
|
||
/Delivery failed: HTTP 500/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||
);
|
||
expect(await result()).not.toContain(SECRET);
|
||
respondWith = 200;
|
||
});
|
||
|
||
it('keeps the saved URL when only the service changes', async () => {
|
||
got.length = 0;
|
||
await page.selectOption('#webhookKind', 'generic');
|
||
await page.click('#webhookSaveBtn');
|
||
await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||
await page.click('#webhookTestBtn');
|
||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||
expect(JSON.parse(got[0].body)).toMatchObject({ event: 'webhook:test', urgency: 'info' });
|
||
});
|
||
|
||
const savedWebhook = () => page.evaluate(async () => (await (await fetch('/api/webhook')).json()).data);
|
||
const modalOpen = () =>
|
||
page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'));
|
||
|
||
it('the main Settings Save also saves a pending webhook edit', async () => {
|
||
await page.selectOption('#webhookScope', 'all');
|
||
await page.click('#appSettingsModal .set-foot .btn-primary');
|
||
await page.waitForFunction(() => !document.getElementById('appSettingsModal')!.classList.contains('active'));
|
||
expect(await savedWebhook()).toMatchObject({ scope: 'all', kind: 'generic', enabled: true, hasUrl: true });
|
||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||
await page.waitForFunction(() => (window as any).app._webhookLoaded?.scope === 'all');
|
||
});
|
||
|
||
it('a refused webhook keeps the modal open with the pasted URL, instead of a silent success', async () => {
|
||
await page.fill('#webhookUrl', 'http://169.254.169.254/latest');
|
||
await page.click('#appSettingsModal .set-foot .btn-primary');
|
||
await page.waitForFunction(() =>
|
||
/metadata|link-local/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||
);
|
||
expect(await modalOpen()).toBe(true);
|
||
expect(await page.inputValue('#webhookUrl')).toBe('http://169.254.169.254/latest');
|
||
expect((await savedWebhook()).urlMasked).toBe(`http://127.0.0.1:${receiverPort}/•••`);
|
||
await page.fill('#webhookUrl', '');
|
||
});
|
||
|
||
it('Send test saves a newly pasted URL first, so it never tests the old one', async () => {
|
||
got.length = 0;
|
||
await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/other-topic`);
|
||
await page.click('#webhookTestBtn');
|
||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0].url).toBe('/other-topic');
|
||
expect(await page.inputValue('#webhookUrl')).toBe('');
|
||
});
|
||
|
||
it('Remove URL deletes the saved secret and turns the channel off', async () => {
|
||
expect(await page.isVisible('#webhookClearBtn')).toBe(true);
|
||
page.once('dialog', (d) => void d.accept());
|
||
await page.click('#webhookClearBtn');
|
||
await page.waitForFunction(() => /removed/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||
expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.');
|
||
expect(await page.isChecked('#webhookEnabled')).toBe(false);
|
||
expect(await page.isVisible('#webhookClearBtn')).toBe(false);
|
||
expect(await savedWebhook()).toMatchObject({ hasUrl: false, enabled: false, urlMasked: '' });
|
||
});
|
||
});
|