mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Merge pull request #523 from opticon454/feat/webhook-notifications
feat(notifications): ntfy/Slack/Discord/generic webhook for the push events # Conflicts: # config/test-suites.ts # docs/api-reference.md # src/web/public/settings-ui.js # src/web/routes/index.ts # src/web/server.ts
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @fileoverview /api/webhook: the webhook-notification config. The URL is a bearer secret, so it
|
||||
* is never returned and the routes are admin only in multi-user mode.
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerWebhookRoutes } from '../../src/web/routes/webhook-routes.js';
|
||||
import { readWebhookConfig, webhookConfigPath, WebhookNotifier, type WebhookFetch } from '../../src/webhook-notify.js';
|
||||
|
||||
const SECRET_URL = 'https://hooks.slack.com/services/T0/B0/SUPERSECRET';
|
||||
|
||||
let dir: string;
|
||||
let fetchImpl: ReturnType<typeof vi.fn<WebhookFetch>>;
|
||||
|
||||
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
|
||||
const notifier = new WebhookNotifier(() => readWebhookConfig(dir), fetchImpl);
|
||||
const h = await createRouteTestHarness(
|
||||
(app) => registerWebhookRoutes(app, { notifier, configDir: dir, hostTitle: () => 'codeman:test' }),
|
||||
authUser ? { authUser } : undefined
|
||||
);
|
||||
return { ...h, notifier };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'webhook-routes-'));
|
||||
fetchImpl = vi.fn<WebhookFetch>(async () => new Response('', { status: 200 }));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/webhook', () => {
|
||||
it('starts disabled with no URL', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(res.json().data).toEqual({
|
||||
enabled: false,
|
||||
kind: 'ntfy',
|
||||
scope: 'attention',
|
||||
hasUrl: false,
|
||||
urlMasked: '',
|
||||
lastResult: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/webhook', () => {
|
||||
it('saves the config, masks the URL in every response, and writes the file 0600', async () => {
|
||||
const { app } = await harness();
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/webhook',
|
||||
payload: { enabled: true, kind: 'slack', scope: 'all', url: SECRET_URL },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().data).toMatchObject({ enabled: true, kind: 'slack', scope: 'all', hasUrl: true });
|
||||
expect(put.json().data.urlMasked).toBe('https://hooks.slack.com/•••');
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
for (const body of [put.body, get.body]) expect(body).not.toMatch(/SUPERSECRET|T0\/B0/);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('changing kind or scope keeps the saved URL (the secret is never re-sent)', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'discord' } });
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ kind: 'discord', url: SECRET_URL, enabled: true });
|
||||
});
|
||||
|
||||
it('an empty url clears it', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.json().data).toMatchObject({ hasUrl: false, urlMasked: '' });
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['enabling with no URL', { enabled: true }, /Add a webhook URL/],
|
||||
['a metadata address', { url: 'http://169.254.169.254/latest' }, /metadata|link-local/],
|
||||
['a non-http scheme', { url: 'file:///etc/passwd' }, /http and https/],
|
||||
['credentials in the URL', { url: 'https://u:p@example.com/x' }, /credentials/],
|
||||
['clearing the URL while enabled', null, /Add a webhook URL/],
|
||||
])('rejects %s with 400 and saves nothing', async (_label, payload, why) => {
|
||||
const { app } = await harness();
|
||||
if (payload === null) {
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
return;
|
||||
}
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ enabled: false, url: '' });
|
||||
});
|
||||
|
||||
it('rejects unknown keys and bad enums (strict schema)', async () => {
|
||||
const { app } = await harness();
|
||||
for (const payload of [{ extra: 1 }, { kind: 'telegram' }, { scope: 'everything' }, { enabled: 'yes' }]) {
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/test', () => {
|
||||
it('refuses with 400 until a URL is saved', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends one message with the saved config, even while notifications are disabled', async () => {
|
||||
const { app, notifier } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'generic', url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: true, status: 200 });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toMatchObject({
|
||||
host: 'codeman:test',
|
||||
event: 'webhook:test',
|
||||
});
|
||||
expect(notifier.lastResult?.ok).toBe(true);
|
||||
expect(res.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
it('reports a delivery failure in data (HTTP 200) without leaking the URL, and GET shows it as the last result', async () => {
|
||||
fetchImpl.mockImplementation(async () => new Response('', { status: 404 }));
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(get.json().data.lastResult).toMatchObject({ ok: false, status: 404 });
|
||||
expect(get.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user', () => {
|
||||
it.each([
|
||||
['GET', '/api/webhook'],
|
||||
['PUT', '/api/webhook'],
|
||||
['POST', '/api/webhook/test'],
|
||||
] as const)('refuses a non-admin on %s %s and touches nothing', async (method, url) => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'bob', role: 'user' });
|
||||
const res = await app.inject({ method, url, payload: method === 'PUT' ? { url: SECRET_URL } : undefined });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an admin', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'root', role: 'admin' });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/webhook' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
// @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 WebhookConfig,
|
||||
type WebhookFetch,
|
||||
type WebhookMessage,
|
||||
} from '../src/webhook-notify.js';
|
||||
import { webviewFetch } 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('<!channel>');
|
||||
expect(r.text).toContain('& 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();
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/** @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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user