Files
TREK/server/tests/unit/nest/notification-transports.test.ts
T
Maurice 2d315df8bf fix(security): keep the operator's ntfy token in and re-check every redirect hop
Two reported advisories, and a third path neither of them covers.

GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.

GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.

The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
2026-09-06 15:02:58 +02:00

616 lines
27 KiB
TypeScript

import { describe, it, expect, vi, afterEach, afterAll, beforeEach } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
}));
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: vi.fn((v) => v),
maybe_encrypt_api_key: vi.fn((v) => v),
}));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
writeAudit: vi.fn(),
getClientIp: vi.fn(),
}));
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
vi.stubGlobal('fetch', vi.fn());
// ssrfGuard is mocked per-test in the SSRF describe block; default passes all.
// safeFetchFollow is modelled rather than stubbed away: the transports call it
// instead of fetch now, and the assertions below still speak in terms of the
// stubbed global fetch, so the fake has to guard first and then hand the request
// over exactly like the real one does.
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
const checkSsrf = vi.fn(async (_url: string) => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }));
return {
checkSsrf,
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError,
safeFetchFollow: vi.fn(async (url: string, init?: RequestInit) => {
const verdict = await checkSsrf(url);
if (!verdict.allowed) {
throw new SsrfBlockedError((verdict as { error?: string }).error ?? 'Request blocked by SSRF guard');
}
return fetch(url, init);
}),
};
});
import { DatabaseService } from '../../../src/nest/database/database.service';
import { getEventText, buildEmailHtml } from '../../../src/nest/notifications/mailer/email-html';
import { WebhookService, buildWebhookBody } from '../../../src/nest/notifications/transports/webhook.service';
import { NtfyService, resolveNtfyUrl, resolveAdminNtfyUrl, resolveNtfyToken, type NtfyConfig } from '../../../src/nest/notifications/transports/ntfy.service';
// The transports are providers now; the db stub below is the same one the
// module-level `db` mock used to supply, handed in instead of imported.
const stubDb = { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) } as unknown as ConstructorParameters<typeof DatabaseService>[0];
const webhookSvc = new WebhookService(new DatabaseService(stubDb));
const ntfySvc = new NtfyService(new DatabaseService(stubDb));
const sendWebhook = webhookSvc.sendWebhook.bind(webhookSvc);
const sendNtfy = ntfySvc.sendNtfy.bind(ntfySvc);
import { checkSsrf } from '../../../src/utils/ssrfGuard';
import { logError } from '../../../src/nest/audit/audit-log.logger';
afterEach(() => {
vi.unstubAllEnvs();
});
// ── getEventText ─────────────────────────────────────────────────────────────
describe('getEventText', () => {
const params = {
trip: 'Tokyo Adventure',
actor: 'Alice',
invitee: 'Bob',
booking: 'Hotel Sakura',
type: 'hotel',
count: '5',
preview: 'See you there!',
category: 'Clothing',
};
it('returns English title and body for lang=en', () => {
const result = getEventText('en', 'trip_invite', params);
expect(result.title).toBeTruthy();
expect(result.body).toBeTruthy();
expect(result.title).toContain('Tokyo Adventure');
expect(result.body).toContain('Alice');
});
it('returns German text for lang=de', () => {
const result = getEventText('de', 'trip_invite', params);
expect(result.title).toContain('Tokyo Adventure');
// German version uses "Einladung"
expect(result.title).toContain('Einladung');
});
it('falls back to English for unknown language code', () => {
const en = getEventText('en', 'trip_invite', params);
const unknown = getEventText('xx', 'trip_invite', params);
expect(unknown.title).toBe(en.title);
expect(unknown.body).toBe(en.body);
});
it('interpolates params into trip_invite correctly', () => {
const result = getEventText('en', 'trip_invite', params);
expect(result.title).toContain('Tokyo Adventure');
expect(result.body).toContain('Alice');
expect(result.body).toContain('Bob');
});
it('all 7 event types produce non-empty title and body in English', () => {
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
for (const event of events) {
const result = getEventText('en', event, params);
expect(result.title, `title for ${event}`).toBeTruthy();
expect(result.body, `body for ${event}`).toBeTruthy();
}
});
it('all 7 event types produce non-empty title and body in German', () => {
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
for (const event of events) {
const result = getEventText('de', event, params);
expect(result.title, `de title for ${event}`).toBeTruthy();
expect(result.body, `de body for ${event}`).toBeTruthy();
}
});
});
// ── buildWebhookBody ─────────────────────────────────────────────────────────
describe('buildWebhookBody', () => {
const payload = {
event: 'trip_invite',
title: 'Trip Invite',
body: 'Alice invited you',
tripName: 'Tokyo Adventure',
};
it('Discord URL produces embeds array format', () => {
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
expect(body).toHaveProperty('embeds');
expect(Array.isArray(body.embeds)).toBe(true);
expect(body.embeds[0]).toHaveProperty('title');
expect(body.embeds[0]).toHaveProperty('description', payload.body);
expect(body.embeds[0]).toHaveProperty('color');
expect(body.embeds[0]).toHaveProperty('footer');
expect(body.embeds[0]).toHaveProperty('timestamp');
});
it('Discord embed title is prefixed with compass emoji', () => {
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
expect(body.embeds[0].title).toContain('📍');
expect(body.embeds[0].title).toContain(payload.title);
});
it('Discord embed footer contains trip name when provided', () => {
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
expect(body.embeds[0].footer.text).toContain('Tokyo Adventure');
});
it('Discord embed footer defaults to TREK when no trip name', () => {
const noTrip = { ...payload, tripName: undefined };
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', noTrip));
expect(body.embeds[0].footer.text).toBe('TREK');
});
it('discordapp.com URL is also detected as Discord', () => {
const body = JSON.parse(buildWebhookBody('https://discordapp.com/api/webhooks/123/abc', payload));
expect(body).toHaveProperty('embeds');
});
it('Slack URL produces text field format', () => {
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', payload));
expect(body).toHaveProperty('text');
expect(body.text).toContain(payload.title);
expect(body.text).toContain(payload.body);
});
it('Slack text includes italic trip name when provided', () => {
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', payload));
expect(body.text).toContain('Tokyo Adventure');
});
it('Slack text omits trip name when not provided', () => {
const noTrip = { ...payload, tripName: undefined };
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', noTrip));
// Should not contain the trip name string
expect(body.text).not.toContain('Tokyo Adventure');
});
it('generic URL produces plain JSON with original fields plus timestamp and source', () => {
const body = JSON.parse(buildWebhookBody('https://mywebhook.example.com/hook', payload));
expect(body).toHaveProperty('event', payload.event);
expect(body).toHaveProperty('title', payload.title);
expect(body).toHaveProperty('body', payload.body);
expect(body).toHaveProperty('timestamp');
expect(body).toHaveProperty('source', 'TREK');
});
});
// ── buildEmailHtml ────────────────────────────────────────────────────────────
describe('buildEmailHtml', () => {
it('returns a string containing <!DOCTYPE html>', () => {
const html = buildEmailHtml('Test Subject', 'Test body text', 'en');
expect(html).toContain('<!DOCTYPE html>');
});
it('contains the subject text', () => {
const html = buildEmailHtml('My Email Subject', 'Some body', 'en');
expect(html).toContain('My Email Subject');
});
it('contains the body text', () => {
const html = buildEmailHtml('Subject', 'Hello world, this is the body!', 'en');
expect(html).toContain('Hello world, this is the body!');
});
it('uses English i18n strings for lang=en', () => {
const html = buildEmailHtml('Subject', 'Body', 'en');
expect(html).toContain('notifications enabled in TREK');
});
it('uses German i18n strings for lang=de', () => {
const html = buildEmailHtml('Subject', 'Body', 'de');
expect(html).toContain('TREK aktiviert');
});
it('falls back to English i18n for unknown language', () => {
const en = buildEmailHtml('Subject', 'Body', 'en');
const unknown = buildEmailHtml('Subject', 'Body', 'xx');
// Both should have the same footer text
expect(unknown).toContain('notifications enabled in TREK');
});
});
// ── SEC: XSS escaping in buildEmailHtml ──────────────────────────────────────
describe('buildEmailHtml XSS prevention (SEC-016)', () => {
it('escapes HTML special characters in subject', () => {
const html = buildEmailHtml('<script>alert(1)</script>', 'Body', 'en');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
it('escapes HTML special characters in body', () => {
const html = buildEmailHtml('Subject', '<img src=x onerror=alert(1)>', 'en');
expect(html).toContain('&lt;img');
expect(html).not.toContain('<img src=x');
});
it('escapes double quotes in subject to prevent attribute injection', () => {
const html = buildEmailHtml('He said "hello"', 'Body', 'en');
expect(html).toContain('&quot;');
expect(html).not.toContain('"hello"');
});
it('escapes ampersands in body', () => {
const html = buildEmailHtml('Subject', 'a & b', 'en');
expect(html).toContain('&amp;');
expect(html).not.toMatch(/>[^<]*a & b[^<]*</);
});
it('escapes user-controlled actor and preview in collab_message body', () => {
const { body } = getEventText('en', 'collab_message', {
trip: 'MyTrip',
actor: '<evil>',
preview: '<script>xss()</script>',
});
const html = buildEmailHtml('Subject', body, 'en');
expect(html).not.toContain('<evil>');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;evil&gt;');
expect(html).toContain('&lt;script&gt;');
});
});
// ── SEC: SSRF protection in sendWebhook ──────────────────────────────────────
describe('sendWebhook SSRF protection (SEC-017)', () => {
const payload = { event: 'test', title: 'T', body: 'B' };
beforeEach(() => {
vi.mocked(logError).mockClear();
});
it('allows a public URL and calls fetch', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
vi.mocked(checkSsrf).mockResolvedValueOnce({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
const result = await sendWebhook('https://example.com/hook', payload);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalled();
});
it('blocks loopback address and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
error: 'Requests to loopback and link-local addresses are not allowed',
});
const result = await sendWebhook('http://localhost/secret', payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
});
it('blocks cloud metadata endpoint (169.254.169.254) and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '169.254.169.254',
error: 'Requests to loopback and link-local addresses are not allowed',
});
const result = await sendWebhook('http://169.254.169.254/latest/meta-data', payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
});
it('blocks private network addresses and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
error: 'Requests to private/internal network addresses are not allowed',
});
const result = await sendWebhook('http://192.168.1.1/hook', payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
});
it('blocks non-HTTP protocols', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: false,
error: 'Only HTTP and HTTPS URLs are allowed',
});
const result = await sendWebhook('file:///etc/passwd', payload);
expect(result).toBe(false);
});
it('does not call fetch when SSRF check blocks the URL', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockClear();
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
error: 'blocked',
});
await sendWebhook('http://localhost/secret', payload);
expect(mockFetch).not.toHaveBeenCalled();
});
});
afterAll(() => vi.unstubAllGlobals());
// ── resolveNtfyUrl ────────────────────────────────────────────────────────────
// GHSA-7pqc-fj3c-9346 — this is the live send path, reachable with no test route
// involved at all: anyone can save their own ntfy_server, and the token used to
// fall back to the operator's whatever host that named.
describe('resolveNtfyToken', () => {
const adminCfg: NtfyConfig = { server: 'https://ntfy.operator.example', topic: 'ops', token: 'operator-token' };
it('withholds the operator token when the user points at their own server', () => {
const user: NtfyConfig = { server: 'https://listener.attacker.example', topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user)).toBeNull();
});
it('sends it when the user rides the operator server, which is the shared setup', () => {
const user: NtfyConfig = { server: null, topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user)).toBe('operator-token');
});
it('matches on a trailing slash, which either side may carry', () => {
const user: NtfyConfig = { server: 'https://ntfy.operator.example/', topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user)).toBe('operator-token');
});
it('does not treat the http twin as the same server', () => {
const user: NtfyConfig = { server: 'http://ntfy.operator.example', topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user)).toBeNull();
});
it("the user's own token always wins, wherever it is going", () => {
const user: NtfyConfig = { server: 'https://elsewhere.example', topic: 't', token: 'mine' };
expect(resolveNtfyToken(adminCfg, user)).toBe('mine');
});
it('both sides unset means ntfy.sh against ntfy.sh, still a match', () => {
const noServer: NtfyConfig = { server: null, topic: 'ops', token: 'operator-token' };
expect(resolveNtfyToken(noServer, { server: null, topic: 't', token: null })).toBe('operator-token');
});
it('honours an explicit server override, which is what the test route passes', () => {
const user: NtfyConfig = { server: null, topic: 't', token: null };
expect(resolveNtfyToken(adminCfg, user, 'https://listener.attacker.example')).toBeNull();
expect(resolveNtfyToken(adminCfg, user, 'https://ntfy.operator.example')).toBe('operator-token');
});
});
describe('resolveNtfyUrl', () => {
const adminCfg: NtfyConfig = { server: 'https://ntfy.sh', topic: 'admin-topic', token: null };
it('returns null when no user config, even when admin topic is set (#1608)', () => {
expect(resolveNtfyUrl(adminCfg, null)).toBeNull();
});
it('returns null when user config has no topic, even when admin topic is set (#1608)', () => {
const user: NtfyConfig = { server: 'https://ntfy.example.com', topic: null, token: null };
expect(resolveNtfyUrl(adminCfg, user)).toBeNull();
});
it('uses user topic with admin server fallback', () => {
const user: NtfyConfig = { server: null, topic: 'my-topic', token: null };
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.sh/my-topic');
});
it('uses user server override', () => {
const user: NtfyConfig = { server: 'https://ntfy.example.com', topic: 'my-topic', token: null };
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.example.com/my-topic');
});
it('strips trailing slash from server', () => {
const user: NtfyConfig = { server: 'https://ntfy.example.com/', topic: 'alerts', token: null };
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.example.com/alerts');
});
it('falls back to https://ntfy.sh when no server configured', () => {
const noServer: NtfyConfig = { server: null, topic: null, token: null };
const user: NtfyConfig = { server: null, topic: 'my-topic', token: null };
expect(resolveNtfyUrl(noServer, user)).toBe('https://ntfy.sh/my-topic');
});
});
describe('resolveAdminNtfyUrl', () => {
it('builds URL from admin topic and server', () => {
expect(resolveAdminNtfyUrl({ server: 'https://ntfy.example.com', topic: 'admin-topic', token: null })).toBe('https://ntfy.example.com/admin-topic');
});
it('returns null when no admin topic', () => {
expect(resolveAdminNtfyUrl({ server: 'https://ntfy.sh', topic: null, token: null })).toBeNull();
});
it('falls back to https://ntfy.sh when no server configured', () => {
expect(resolveAdminNtfyUrl({ server: null, topic: 'alerts', token: null })).toBe('https://ntfy.sh/alerts');
});
it('strips trailing slash from server', () => {
expect(resolveAdminNtfyUrl({ server: 'https://ntfy.sh/', topic: 'alerts', token: null })).toBe('https://ntfy.sh/alerts');
});
});
// ── sendNtfy ─────────────────────────────────────────────────────────────────
describe('sendNtfy', () => {
const ntfyUrl = 'https://ntfy.sh/trek-test';
const payload = { event: 'trip_invite', title: 'Test Title', body: 'Test body' };
beforeEach(() => {
vi.mocked(logError).mockClear();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
vi.mocked(checkSsrf).mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
});
it('NTFY-001 — sends POST to topic URL with plain text body and metadata in headers', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
const result = await sendNtfy(ntfyUrl, null, payload);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledOnce();
const [calledUrl, calledOpts] = mockFetch.mock.calls[0];
expect(calledUrl).toBe(ntfyUrl);
// Body should be plain text, not JSON
expect(calledOpts.body).toBe('Test body');
// Title, Priority, Tags go in headers
expect(calledOpts.headers['Title']).toBe('Test Title');
expect(calledOpts.headers['Priority']).toBe('4'); // trip_invite maps to priority 4
expect(calledOpts.headers['Tags']).toContain('loudspeaker');
});
it('NTFY-002 — attaches Bearer token when token provided', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, 'my-secret-token', payload);
const [, calledOpts] = mockFetch.mock.calls[0];
expect(calledOpts.headers['Authorization']).toBe('Bearer my-secret-token');
});
it('NTFY-003 — no Authorization header when token is null', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, payload);
const [, calledOpts] = mockFetch.mock.calls[0];
expect(calledOpts.headers['Authorization']).toBeUndefined();
});
it('NTFY-004 — includes Click header when link is provided', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, { ...payload, link: 'https://trek.example.com/trips/5' });
const [, calledOpts] = mockFetch.mock.calls[0];
expect(calledOpts.headers['Click']).toBe('https://trek.example.com/trips/5');
});
it('NTFY-005 — SSRF guard blocks private URL and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
error: 'Requests to private/internal network addresses are not allowed',
});
const result = await sendNtfy('http://192.168.1.1/ntfy', null, payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
expect(globalThis.fetch as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
});
it('NTFY-006 — HTTP non-2xx response returns false and logs error', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: false, status: 403, text: async () => 'Forbidden' } as never);
const result = await sendNtfy(ntfyUrl, null, payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('403'));
});
it('NTFY-007 — network error returns false', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockRejectedValueOnce(new Error('Network failure'));
const result = await sendNtfy(ntfyUrl, null, payload);
expect(result).toBe(false);
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('Network failure'));
});
it('NTFY-008 — unknown event falls back to priority 3 and no Tags header', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, { event: 'unknown_event', title: 'T', body: 'B' });
const [, calledOpts] = mockFetch.mock.calls[0];
expect(calledOpts.headers['Priority']).toBe('3');
expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header
});
it('NTFY-009a — a title carrying CR/LF is RFC 2047 encoded instead of reaching the header raw', async () => {
// undici rejects a header value with a control character, so a trip titled
// with a stray newline used to drop the notification for every recipient.
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
const result = await sendNtfy(ntfyUrl, null, { ...payload, title: 'Trip\r\nX-Injected: 1' });
expect(result).toBe(true);
const [, calledOpts] = mockFetch.mock.calls[0];
const encoded = calledOpts.headers['Title'] as string;
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
expect(encoded).not.toContain('\n');
const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, '');
expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Trip\r\nX-Injected: 1');
});
it('NTFY-009b — a plain ASCII title is still sent verbatim', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Rome 2026 - day 3' });
const [, calledOpts] = mockFetch.mock.calls[0];
expect(calledOpts.headers['Title']).toBe('Rome 2026 - day 3');
});
it('NTFY-009 — title with non-Latin-1 chars is RFC 2047 encoded', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Buy →€ ticket' });
const [, calledOpts] = mockFetch.mock.calls[0];
const encoded = calledOpts.headers['Title'] as string;
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, '');
expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Buy →€ ticket');
});
it('NTFY-009b — title with Latin-1 umlauts is RFC 2047 encoded, not sent as raw bytes', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Frühstück in Zürich' });
const [, calledOpts] = mockFetch.mock.calls[0];
const encoded = calledOpts.headers['Title'] as string;
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, '');
expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Frühstück in Zürich');
});
it('NTFY-010 — ASCII-only title is passed through verbatim', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Simple ASCII title' });
const [, calledOpts] = mockFetch.mock.calls[0];
expect(calledOpts.headers['Title']).toBe('Simple ASCII title');
});
});