Files
TREK/server/tests/unit/nest/notification-transports.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

787 lines
34 KiB
TypeScript

import { logError } from '../../../src/nest/audit/audit-log.logger';
import { buildBuiltinChannels } from '../../../src/nest/notifications/channels/builtins';
import { getEventText, buildEmailHtml } from '../../../src/nest/notifications/mailer/email-html';
import {
NtfyService,
resolveNtfyUrl,
resolveAdminNtfyUrl,
resolveNtfyToken,
type NtfyConfig,
} from '../../../src/nest/notifications/transports/ntfy.service';
import { WebhookService, buildWebhookBody } from '../../../src/nest/notifications/transports/webhook.service';
import { checkSsrf } from '../../../src/utils/ssrfGuard';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { setAppSetting, setUserSetting } from '../../helpers/factories/settings';
import { makeAdmin, makeUser } from '../../helpers/factories/users';
import { createTestSettingsRepo, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { sharedTestOrm } from '../../helpers/test-uow';
import { describe, it, expect, vi, afterEach, afterAll, beforeAll, 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);
}),
};
});
// The transports are providers now, taking SettingsRepository/
// AppSettingsRepository instead of DatabaseService — a real, throwaway
// in-memory DB (never read by any case in this file: every case here drives
// sendWebhook/sendNtfy directly, never the config getters — including the
// GHSA-7pqc-fj3c-9346 case below, which hand-builds both NtfyConfigs and never
// reaches ntfy.getUserNtfyConfig/getAdminNtfyConfig or the production send
// path; the "GHSA-7pqc live path" case further down is the one that seeds this
// same DB and resolves real configs through the repositories, driving
// buildBuiltinChannels(...).sendToUser) replaces the STUB-DB fixture the fake
// `DatabaseService` connection used to supply.
let webhookSvc: WebhookService;
let ntfySvc: NtfyService;
let sendWebhook: WebhookService['sendWebhook'];
let sendNtfy: NtfyService['sendNtfy'];
beforeAll(async () => {
const transportsDb = createSnapshotTestDb();
const settingsRepo = await createTestSettingsRepo(transportsDb);
const appSettingsRepo = await createTestAppSettingsRepo(transportsDb);
webhookSvc = new WebhookService(settingsRepo, appSettingsRepo);
ntfySvc = new NtfyService(settingsRepo, appSettingsRepo);
sendWebhook = webhookSvc.sendWebhook.bind(webhookSvc);
sendNtfy = ntfySvc.sendNtfy.bind(ntfySvc);
});
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('links notification preferences to the notifications settings tab', () => {
const html = buildEmailHtml('Subject', 'Body', 'en');
expect(html).toMatch(
/<a href="https?:\/\/[^"]+\/settings\?tab=notifications"[^>]*>Manage preferences in Settings<\/a>/,
);
});
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');
});
it('points the header logo at an inline part by Content-ID, not at a data: URI (#2507)', () => {
const html = buildEmailHtml('Subject', 'Body', 'en');
// Gmail strips data: URIs and Outlook blocks them, which left a broken image
// in the header of every mail.
expect(html).not.toContain('data:');
expect(html).toMatch(/<img src="cid:[^"]+" alt="TREK" width="48" height="48"/);
});
});
// ── 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');
});
});
// R2 (Task 0's report, verbatim spec): drives the REAL end-to-end path —
// resolve the URL and token exactly the way production does, then call the
// real sendNtfy — rather than unit-testing resolveNtfyToken in isolation
// (the block above). Mutation-proved: temporarily swap isOperatorNtfyServer's
// target-based URL comparison for a role-based check (`return true` /
// `ctx.user.role === 'admin'`-shaped) and this test's second assertion goes
// red (the admin token would be attached to the non-operator send too).
describe("GHSA-7pqc-fj3c-9346: ntfy token is only attached when the target is the operator's own server", () => {
const adminCfg: NtfyConfig = {
server: 'https://ntfy.operator.example',
topic: 'ops',
token: 'operator-secret-token',
};
// The "user token" is deliberately null in both fixtures, to isolate the
// admin-token-leak path specifically — if the user had their own token,
// resolveNtfyToken would return it first and the admin-scope branch would
// never be reached, per its own precedence.
const operatorTargetUser: NtfyConfig = { server: null, topic: 'user-topic', token: null }; // rides the operator's own server
const nonOperatorTargetUser: NtfyConfig = {
server: 'https://ntfy.attacker.example',
topic: 'user-topic',
token: null,
}; // a different, user-chosen server
const payload = { event: 'trip_reminder', title: 'Trip reminder', body: 'Your trip starts soon' };
beforeEach(() => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
vi.mocked(checkSsrf).mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
});
it('attaches the admin token on an operator-server send, and withholds it on a non-operator-server send', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValue({ ok: true, text: async () => '' } as never);
await sendNtfy(
resolveNtfyUrl(adminCfg, operatorTargetUser)!,
resolveNtfyToken(adminCfg, operatorTargetUser),
payload,
);
expect(mockFetch.mock.calls[0][1].headers['Authorization']).toBe('Bearer operator-secret-token');
await sendNtfy(
resolveNtfyUrl(adminCfg, nonOperatorTargetUser)!,
resolveNtfyToken(adminCfg, nonOperatorTargetUser),
payload,
);
expect(mockFetch.mock.calls[1][1].headers['Authorization']).toBeUndefined();
});
});
// M4 fix-wave (task-7-review.md): the case above drives resolveNtfyToken/sendNtfy
// directly and never reaches ntfy.getUserNtfyConfig/getAdminNtfyConfig or the
// production dispatch path (channels/builtins.ts sendToUser) — reverting the fix
// on that live-send leg (`resolveNtfyToken(adminCfg, userCfg)` back to
// `userCfg?.token ?? adminCfg.token` at builtins.ts:98) leaves every committed
// test in this file green. This case seeds the DB for real, builds NtfyService
// on the real repositories, and drives buildBuiltinChannels(...).sendToUser —
// the same call NotificationsService.send() makes.
describe('GHSA-7pqc-fj3c-9346 (live path): buildBuiltinChannels sendToUser resolves configs through the repositories', () => {
it('operator-server user gets the admin token; foreign-server user does not', async () => {
const liveDb = createSnapshotTestDb();
const liveOrm = await sharedTestOrm(liveDb);
await makeAdmin(liveOrm, { id: 1, username: 'op', email: 'op@x' });
await makeUser(liveOrm, { id: 2, username: 'u2', email: 'u2@x' });
await setAppSetting(liveOrm, 'admin_ntfy_server', 'https://ntfy.operator.example');
await setAppSetting(liveOrm, 'admin_ntfy_topic', 'ops');
await setAppSetting(liveOrm, 'admin_ntfy_token', 'operator-secret');
await setUserSetting(liveOrm, 1, 'ntfy_topic', 't1');
await setUserSetting(liveOrm, 2, 'ntfy_topic', 't2');
await setUserSetting(liveOrm, 2, 'ntfy_server', 'https://ntfy.attacker.example');
const liveNtfy = new NtfyService(await createTestSettingsRepo(liveDb), await createTestAppSettingsRepo(liveDb));
const ntfyChannel = buildBuiltinChannels({
mailer: {} as never,
webhook: {} as never,
ntfy: liveNtfy,
push: {} as never,
}).find((c) => c.id === 'ntfy')!;
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockClear();
mockFetch.mockResolvedValue({ ok: true, text: async () => '' } as never);
vi.mocked(checkSsrf).mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
await ntfyChannel.sendToUser(1, { event: 'trip_reminder', title: 'T', body: 'B' });
await ntfyChannel.sendToUser(2, { event: 'trip_reminder', title: 'T', body: 'B' });
expect(mockFetch.mock.calls[0][0]).toBe('https://ntfy.operator.example/t1');
expect(
(mockFetch.mock.calls[0][1] as RequestInit & { headers: Record<string, string> }).headers['Authorization'],
).toBe('Bearer operator-secret');
expect(mockFetch.mock.calls[1][0]).toBe('https://ntfy.attacker.example/t2');
expect(
(mockFetch.mock.calls[1][1] as RequestInit & { headers: Record<string, string> }).headers['Authorization'],
).toBeUndefined();
});
});
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');
});
});