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
@@ -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
});
});