feat(notifications): add ntfy as a first-class notification channel

Adds ntfy.sh (and self-hosted instances) as a new push notification
channel with full parity to the existing webhook channel.

- Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig,
  resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/
  Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per
  event), SSRF guard via existing checkSsrf + createPinnedDispatcher
- notificationPreferencesService: ntfy added to NotifChannel union,
  IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels,
  ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface
- notificationService: per-user ntfy dispatch after webhook block;
  admin-scoped ntfy via getAdminGlobalPref for version_available events
- Routes: POST /api/notifications/test-ntfy with saved-token fallback
- authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS,
  masked + encrypted on read/write
- settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS
- Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in
  NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method
- i18n: full English strings; English placeholders in 14 other locales
- Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests,
  MSW handler for test-ntfy endpoint
This commit is contained in:
jubnl
2026-04-15 13:59:25 +02:00
parent f349e567f8
commit bfe84b3016
30 changed files with 1241 additions and 52 deletions
+21 -1
View File
@@ -1,7 +1,7 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications';
import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications';
import {
getNotifications,
getUnreadCount,
@@ -47,6 +47,26 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) =
res.json(await testWebhook(url));
});
router.post('/test-ntfy', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { topic, server, token } = req.body as { topic?: string; server?: string; token?: string };
// Always load saved config for fallbacks (token may be masked or absent in request)
const userCfg = getUserNtfyConfig(authReq.user.id);
const adminCfg = getAdminNtfyConfig();
const resolvedTopic = topic || userCfg?.topic || undefined;
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
// Reuse saved token when request sends null, empty, or the masked placeholder
const resolvedToken = (token && token !== '••••••••')
? token
: (userCfg?.token ?? adminCfg.token ?? null);
if (!resolvedTopic) return res.status(400).json({ error: 'No ntfy topic configured' });
res.json(await testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken }));
});
// ── In-app notifications ──────────────────────────────────────────────────────
// GET /in-app — list notifications (paginated)
+5 -2
View File
@@ -30,7 +30,7 @@ const MFA_BACKUP_CODE_COUNT = 10;
const ADMIN_SETTINGS_KEYS = [
'allow_registration', 'allowed_file_types', 'require_mfa',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
'notification_channels', 'admin_webhook_url',
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
];
@@ -714,7 +714,7 @@ export function getAppSettings(userId: number): { error?: string; status?: numbe
const result: Record<string, string> = {};
for (const key of ADMIN_SETTINGS_KEYS) {
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url') ? '••••••••' : row.value;
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : row.value;
}
return { data: result };
}
@@ -768,6 +768,8 @@ export function updateAppSettings(
if (key === 'smtp_pass') val = encrypt_api_key(val);
if (key === 'admin_webhook_url' && val === '••••••••') continue;
if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val;
if (key === 'admin_ntfy_token' && val === '••••••••') continue;
if (key === 'admin_ntfy_token' && val) val = maybe_encrypt_api_key(val) ?? val;
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
}
}
@@ -778,6 +780,7 @@ export function updateAppSettings(
const smtpChanged = changedKeys.some(k => k.startsWith('smtp_'));
if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels;
if (changedKeys.includes('admin_webhook_url')) summary.admin_webhook_url_updated = true;
if (changedKeys.some(k => k.startsWith('admin_ntfy_'))) summary.admin_ntfy_updated = true;
if (smtpChanged) summary.smtp_settings_updated = true;
if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration;
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
@@ -3,7 +3,7 @@ import { decrypt_api_key } from './apiKeyCrypto';
// ── Types ──────────────────────────────────────────────────────────────────
export type NotifChannel = 'email' | 'webhook' | 'inapp';
export type NotifChannel = 'email' | 'webhook' | 'inapp' | 'ntfy';
export type NotifEventType =
| 'trip_invite'
@@ -20,19 +20,20 @@ export interface AvailableChannels {
email: boolean;
webhook: boolean;
inapp: boolean;
ntfy: boolean;
}
// Which channels are implemented for each event type.
// Only implemented combos show toggles in the user preferences UI.
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
trip_invite: ['inapp', 'email', 'webhook'],
booking_change: ['inapp', 'email', 'webhook'],
trip_reminder: ['inapp', 'email', 'webhook'],
vacay_invite: ['inapp', 'email', 'webhook'],
photos_shared: ['inapp', 'email', 'webhook'],
collab_message: ['inapp', 'email', 'webhook'],
packing_tagged: ['inapp', 'email', 'webhook'],
version_available: ['inapp', 'email', 'webhook'],
trip_invite: ['inapp', 'email', 'webhook', 'ntfy'],
booking_change: ['inapp', 'email', 'webhook', 'ntfy'],
trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'],
vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'],
photos_shared: ['inapp', 'email', 'webhook', 'ntfy'],
collab_message: ['inapp', 'email', 'webhook', 'ntfy'],
packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'],
version_available: ['inapp', 'email', 'webhook', 'ntfy'],
synology_session_cleared: ['inapp'],
};
@@ -55,7 +56,7 @@ function getAppSetting(key: string): string | null {
export function getActiveChannels(): NotifChannel[] {
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
if (raw === 'none') return [];
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook');
return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook' || c === 'ntfy');
}
/**
@@ -64,8 +65,8 @@ export function getActiveChannels(): NotifChannel[] {
*/
export function getAvailableChannels(): AvailableChannels {
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
const hasWebhook = getActiveChannels().includes('webhook');
return { email: hasSmtp, webhook: hasWebhook, inapp: true };
const activeChannels = getActiveChannels();
return { email: hasSmtp, webhook: activeChannels.includes('webhook'), ntfy: activeChannels.includes('ntfy'), inapp: true };
}
// ── Per-user preference checks ─────────────────────────────────────────────
@@ -115,8 +116,8 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
const channels = IMPLEMENTED_COMBOS[eventType];
preferences[eventType] = {};
for (const channel of channels) {
// Admin-scoped events use global settings for email/webhook
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook')) {
// Admin-scoped events use global settings for email/webhook/ntfy
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook' || channel === 'ntfy')) {
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
} else {
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
@@ -134,12 +135,14 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
if (scope === 'admin') {
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, inapp: true };
const hasAdminNtfy = !!(getAppSetting('admin_ntfy_topic'));
available_channels = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy, inapp: true };
} else {
const activeChannels = getActiveChannels();
available_channels = {
email: activeChannels.includes('email'),
webhook: activeChannels.includes('webhook'),
ntfy: activeChannels.includes('ntfy'),
inapp: true,
};
}
@@ -154,19 +157,19 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
// ── Admin global preferences (stored in app_settings) ─────────────────────
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook'];
const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook', 'ntfy'];
/**
* Returns the global admin preference for an event+channel.
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
* Defaults to true (enabled) when no row exists.
*/
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook'): boolean {
export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy'): boolean {
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
return val !== '0';
}
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook', enabled: boolean): void {
function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy', enabled: boolean): void {
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
`admin_notif_pref_${event}_${channel}`,
enabled ? '1' : '0'
@@ -250,7 +253,7 @@ export function setAdminPreferences(
for (const [eventType, channels] of Object.entries(globalPrefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled);
setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook' | 'ntfy', enabled);
}
}
@@ -7,15 +7,20 @@ import {
isSmtpConfigured,
ADMIN_SCOPED_EVENTS,
type NotifEventType,
type NotifChannel,
} from './notificationPreferencesService';
import {
getEventText,
sendEmail,
sendWebhook,
sendNtfy,
getUserEmail,
getUserLanguage,
getUserWebhookUrl,
getAdminWebhookUrl,
getUserNtfyConfig,
getAdminNtfyConfig,
resolveNtfyUrl,
getAppUrl,
} from './notifications';
import {
@@ -270,6 +275,19 @@ export async function send(payload: NotificationPayload): Promise<void> {
}
}
// ── Ntfy (per-user) — skip for admin-scoped events (handled globally below) ──
if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('ntfy') && isEnabledForEvent(recipientId, event, 'ntfy' as NotifChannel)) {
const userNtfyCfg = getUserNtfyConfig(recipientId);
const adminNtfyCfg = getAdminNtfyConfig();
const ntfyUrl = resolveNtfyUrl(adminNtfyCfg, userNtfyCfg);
if (ntfyUrl) {
const lang = getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
const token = userNtfyCfg?.token ?? adminNtfyCfg.token;
promises.push(sendNtfy(ntfyUrl, token, { event, title, body, link: fullLink }));
}
}
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === 'rejected') {
@@ -288,4 +306,16 @@ export async function send(payload: NotificationPayload): Promise<void> {
});
}
}
// ── Admin ntfy (scope: admin) — global, respects global pref ─────────
if (scope === 'admin' && getAdminGlobalPref(event, 'ntfy')) {
const adminNtfyCfg = getAdminNtfyConfig();
const adminNtfyUrl = resolveNtfyUrl(adminNtfyCfg, null);
if (adminNtfyUrl) {
const { title, body } = getEventText('en', event, params);
await sendNtfy(adminNtfyUrl, adminNtfyCfg.token, { event, title, body, link: fullLink }).catch((err: unknown) => {
logError(`notificationService.send admin ntfy failed event=${event}: ${err instanceof Error ? err.message : err}`);
});
}
}
}
+131
View File
@@ -442,3 +442,134 @@ export async function testWebhook(url: string): Promise<{ success: boolean; erro
}
}
// ── Ntfy ──────────────────────────────────────────────────────────────────
export interface NtfyConfig {
server: string | null;
topic: string | null;
token: string | null;
}
/** Priority and tags mapped to each notification event type. */
const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
trip_invite: { priority: 4, tags: ['loudspeaker'] },
booking_change: { priority: 3, tags: ['calendar'] },
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
vacay_invite: { priority: 4, tags: ['palm_tree'] },
photos_shared: { priority: 3, tags: ['camera'] },
collab_message: { priority: 3, tags: ['speech_balloon'] },
packing_tagged: { priority: 3, tags: ['luggage'] },
version_available: { priority: 4, tags: ['package'] },
synology_session_cleared: { priority: 3, tags: ['warning'] },
};
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
export function getUserNtfyConfig(userId: number): NtfyConfig | null {
const rows = db.prepare(
"SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')"
).all(userId) as { key: string; value: string }[];
if (rows.length === 0) return null;
const map: Record<string, string> = {};
for (const r of rows) map[r.key] = r.value;
return {
topic: map['ntfy_topic'] || null,
server: map['ntfy_server'] || null,
token: map['ntfy_token'] ? decrypt_api_key(map['ntfy_token']) : null,
};
}
export function getAdminNtfyConfig(): NtfyConfig {
const topic = getAppSetting('admin_ntfy_topic') || null;
const server = getAppSetting('admin_ntfy_server') || null;
const rawToken = getAppSetting('admin_ntfy_token') || null;
return {
topic,
server,
token: rawToken ? decrypt_api_key(rawToken) : null,
};
}
/**
* Resolve the ntfy POST URL from admin base config + user override.
* Returns null if topic cannot be determined.
*/
export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null {
const topic = userCfg?.topic || adminCfg.topic;
if (!topic) return null;
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
return `${base}/${encodeURIComponent(topic)}`;
}
export function isNtfyConfiguredForUser(userId: number): boolean {
const cfg = getUserNtfyConfig(userId);
return !!(cfg?.topic);
}
export function isNtfyConfiguredAdmin(): boolean {
return !!(getAppSetting('admin_ntfy_topic'));
}
export async function sendNtfy(
url: string,
token: string | null,
payload: { event: string; title: string; body: string; link?: string },
): Promise<boolean> {
if (!url) return false;
const ssrf = await checkSsrf(url);
if (!ssrf.allowed) {
logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
return false;
}
const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META;
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
const headers: Record<string, string> = {
'Title': payload.title,
'Priority': String(meta.priority),
};
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
if (payload.link) headers['Click'] = payload.link;
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const res = await fetch(url, {
method: 'POST',
headers,
body: payload.body,
signal: AbortSignal.timeout(10000),
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
} as any);
if (!res.ok) {
const errBody = await res.text().catch(() => '');
logError(`Ntfy HTTP ${res.status}: ${errBody}`);
return false;
}
logInfo(`Ntfy sent event=${payload.event}`);
logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`);
return true;
} catch (err) {
logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`);
return false;
}
}
export async function testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<{ success: boolean; error?: string }> {
const adminCfg = getAdminNtfyConfig();
const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null });
if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
try {
const sent = await sendNtfy(url, cfg.token ?? null, {
event: 'test',
title: 'Test Notification',
body: 'This is a test notification from TREK. If you received this, your ntfy configuration is working correctly.',
});
return sent ? { success: true } : { success: false, error: 'Failed to send ntfy notification' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { db } from '../db/database';
import { maybe_encrypt_api_key } from './apiKeyCrypto';
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url']);
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
export function getUserSettings(userId: number): Record<string, unknown> {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
@@ -348,6 +348,43 @@ describe('Notification test endpoints', () => {
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success');
});
it('NOTIF-007 — POST /api/notifications/test-ntfy returns 400 when no topic configured', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/notifications/test-ntfy')
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
it('NOTIF-008 — POST /api/notifications/test-ntfy with explicit topic returns 200', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/notifications/test-ntfy')
.set('Cookie', authCookie(user.id))
.send({ topic: 'trek-integration-test-topic' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success');
});
it('NOTIF-009 — POST /api/notifications/test-ntfy falls back to user saved topic', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', 'saved-user-topic')").run(user.id);
const res = await request(app)
.post('/api/notifications/test-ntfy')
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success');
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -153,14 +153,15 @@ describe('getPreferencesMatrix', () => {
expect(available_channels.email).toBe(false);
});
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => {
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook, ntfy]', () => {
const { user } = createAdmin(testDb);
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']);
// All events now support all three channels
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook', 'ntfy']);
// All events now support all four channels
expect(implemented_combos['trip_invite']).toContain('inapp');
expect(implemented_combos['trip_invite']).toContain('email');
expect(implemented_combos['trip_invite']).toContain('webhook');
expect(implemented_combos['trip_invite']).toContain('ntfy');
});
});
@@ -458,3 +458,72 @@ describe('send() — channel failure resilience', () => {
expect(countAllNotifications()).toBe(1);
});
});
// ── Ntfy dispatch ─────────────────────────────────────────────────────────────
function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): void {
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)").run(userId, topic);
}
function setAdminNtfyTopic(topic = 'trek-admin-alerts'): void {
setAppSetting(testDb, 'admin_ntfy_topic', topic);
}
describe('send() — ntfy channel dispatch', () => {
beforeEach(() => {
fetchMock.mockResolvedValue({ ok: true, text: async () => '' });
});
it('NTFY-SVCB-001 — ntfy fires when channel active and user has topic configured', async () => {
const { user } = createUser(testDb);
setUserNtfyTopic(user.id);
setNotificationChannels(testDb, 'ntfy');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)).lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
expect(ntfyCalls.length).toBeGreaterThan(0);
// Header-based API: metadata in headers, body = plain text
expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // trip_invite = high priority
expect(ntfyCalls[0][1].headers['Tags']).toContain('loudspeaker');
});
it('NTFY-SVCB-002 — ntfy skips when channel not in active channels', async () => {
const { user } = createUser(testDb);
setUserNtfyTopic(user.id);
setNotificationChannels(testDb, 'none');
fetchMock.mockClear();
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
expect(ntfyCalls.length).toBe(0);
});
it('NTFY-SVCB-003 — ntfy skips when user has no topic configured', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'ntfy');
// No ntfy_topic set, but no admin_ntfy_server either — resolveNtfyUrl returns null
fetchMock.mockClear();
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
expect(ntfyCalls.length).toBe(0);
});
it('NTFY-SVCB-004 — admin-scoped version_available fires admin ntfy topic', async () => {
createAdmin(testDb);
setAdminNtfyTopic();
setNotificationChannels(testDb, 'none');
fetchMock.mockClear();
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '3.0.0' } });
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
expect(ntfyCalls.length).toBeGreaterThan(0);
expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // version_available = high priority
expect(ntfyCalls[0][1].headers['Tags']).toContain('package');
});
});
@@ -24,7 +24,7 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
createPinnedDispatcher: vi.fn(() => ({})),
}));
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications';
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, type NtfyConfig } from '../../../src/services/notifications';
import { checkSsrf } from '../../../src/utils/ssrfGuard';
import { logError } from '../../../src/services/auditLog';
@@ -319,3 +319,140 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
});
afterAll(() => vi.unstubAllGlobals());
// ── resolveNtfyUrl ────────────────────────────────────────────────────────────
describe('resolveNtfyUrl', () => {
const adminCfg: NtfyConfig = { server: 'https://ntfy.sh', topic: 'admin-topic', token: null };
it('uses admin server + admin topic when no user config', () => {
expect(resolveNtfyUrl(adminCfg, null)).toBe('https://ntfy.sh/admin-topic');
});
it('uses user topic over admin topic', () => {
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 admin: NtfyConfig = { server: 'https://ntfy.sh/', topic: 'alerts', token: null };
expect(resolveNtfyUrl(admin, null)).toBe('https://ntfy.sh/alerts');
});
it('returns null when no topic in admin or user config', () => {
const noTopic: NtfyConfig = { server: 'https://ntfy.sh', topic: null, token: null };
expect(resolveNtfyUrl(noTopic, null)).toBeNull();
});
it('falls back to https://ntfy.sh when no server configured', () => {
const noServer: NtfyConfig = { server: null, topic: 'my-topic', token: null };
expect(resolveNtfyUrl(noServer, null)).toBe('https://ntfy.sh/my-topic');
});
});
// ── 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
});
});