Files
TREK/server/tests/unit/nest/channel-registry.test.ts
T
Maurice 650451bdf7 feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
2026-09-24 23:17:03 +00:00

174 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for the external notification channel registry.
* Covers CHREG-001 to CHREG-008.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
listChannels,
getChannel,
registerChannel,
setPluginChannelSource,
pluginChannelId,
isPluginChannelId,
__resetChannelsForTest,
} from '../../../src/nest/notifications/channel-registry';
// The channel contract lives in notification-events; channel-registry only consumes it.
import type { ChannelMessage, ExternalChannel } from '../../../src/nest/notifications/notification-events';
import { registerBuiltinChannels } from '../../../src/nest/notifications/channels/builtins';
import type { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import type { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
import type { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
import type { WebPushService } from '../../../src/nest/notifications/transports/web-push.service';
// The built-ins take their transports as an argument now; these cases only care
// that the four ids land in the registry with the right privileges, so the
// transports are stubs.
const pushStub = {
isAvailable: vi.fn(() => true),
hasDevices: vi.fn((userId: number) => userId === 7),
sendToUser: vi.fn(async () => true),
sendTest: vi.fn(async () => ({ success: true })),
};
const stubTransports = {
mailer: { isSmtpConfigured: () => true, getUserEmail: () => null } as unknown as MailerService,
webhook: { getUserWebhookUrl: () => null, getAdminWebhookUrl: () => null } as unknown as WebhookService,
ntfy: { getUserNtfyConfig: () => null, getAdminNtfyConfig: () => ({ server: null, topic: null, token: null }) } as unknown as NtfyService,
push: pushStub as unknown as WebPushService,
};
function fakeChannel(id: string, over: Partial<ExternalChannel> = {}): ExternalChannel {
return {
id,
source: 'plugin',
label: id,
supportsEvent: () => true,
isConfiguredFor: () => true,
sendToUser: async () => true,
...over,
};
}
const MSG: ChannelMessage = { event: 'trip_invite', title: 't', body: 'b' };
beforeEach(() => {
__resetChannelsForTest();
registerBuiltinChannels(stubTransports);
});
afterEach(() => {
setPluginChannelSource(null);
});
describe('channelRegistry', () => {
it('CHREG-001: the four built-in external channels are registered; in-app is not', () => {
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
expect(getChannel('inapp')).toBeUndefined();
});
it('CHREG-002 — plugin channels come from the injected source and are namespaced', () => {
setPluginChannelSource(() => [fakeChannel(pluginChannelId('gotify'))]);
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
expect(getChannel('plugin:gotify')?.source).toBe('plugin');
expect(isPluginChannelId('plugin:gotify')).toBe(true);
expect(isPluginChannelId('email')).toBe(false);
});
it('CHREG-003 — a plugin channel disappears when the runtime stops reporting it', () => {
let live = true;
setPluginChannelSource(() => (live ? [fakeChannel('plugin:gotify')] : []));
expect(getChannel('plugin:gotify')).toBeDefined();
live = false;
expect(getChannel('plugin:gotify')).toBeUndefined();
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
});
it('CHREG-004 — a throwing plugin source cannot take notifications down', () => {
setPluginChannelSource(() => {
throw new Error('runtime exploded');
});
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy', 'push']);
});
it('CHREG-005 — a plugin can never claim a built-in id', () => {
// The prefix is the whole defence: an id without it is not a plugin channel id,
// and pluginChannelId() is the only way the runtime mints one.
expect(pluginChannelId('email')).toBe('plugin:email');
expect(getChannel('email')?.source).toBe('builtin');
});
it('CHREG-006 — only email declares the admin-scoped toggle bypass', () => {
expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true);
expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
expect(getChannel('push')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
});
it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => {
expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true);
expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true);
expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined();
expect(getChannel('push')?.supportsAdminGlobal).toBeUndefined();
expect(getChannel('push')?.sendGlobal).toBeUndefined();
});
it('CHREG-008b: push carries every user event, but neither admin-scoped ones nor synology_session_cleared', () => {
const push = getChannel('push')!;
expect(push.source).toBe('builtin');
expect(push.labelKey).toBe('settings.notificationPreferences.push');
for (const event of ['trip_invite', 'collab_message', 'trip_reminder', 'plugin_notification'] as const) {
expect(push.supportsEvent(event)).toBe(true);
}
expect(push.supportsEvent('version_available')).toBe(false);
expect(push.supportsEvent('replica_failure')).toBe(false);
expect(push.supportsEvent('synology_session_cleared')).toBe(false);
});
it('CHREG-008c: push is configured for a user with a device and hands sends and tests to the transport', async () => {
const push = getChannel('push')!;
expect(push.isConfiguredFor(7)).toBe(true);
expect(push.isConfiguredFor(8)).toBe(false);
await push.sendToUser(7, MSG);
expect(pushStub.sendToUser).toHaveBeenCalledWith(7, MSG);
await expect(push.test!(7)).resolves.toEqual({ success: true });
expect(pushStub.sendTest).toHaveBeenCalledWith(7);
});
it('CHREG-008d: push is ready only while it can sign, and hides meanwhile; a plugin cannot claim that', () => {
const push = getChannel('push')!;
expect(push.hiddenWhileInstanceUnconfigured).toBe(true);
expect(push.isInstanceConfigured!()).toBe(true);
pushStub.isAvailable.mockReturnValueOnce(false);
expect(push.isInstanceConfigured!()).toBe(false);
// Email keeps its column without SMTP, as before.
expect(getChannel('email')?.hiddenWhileInstanceUnconfigured).toBeUndefined();
setPluginChannelSource(() => [fakeChannel('plugin:gotify', { hiddenWhileInstanceUnconfigured: true })]);
expect(getChannel('plugin:gotify')?.hiddenWhileInstanceUnconfigured).toBeUndefined();
});
it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => {
for (const id of ['email', 'webhook', 'ntfy']) {
expect(getChannel(id)!.supportsEvent('trip_invite')).toBe(true);
expect(getChannel(id)!.supportsEvent('version_available')).toBe(true);
expect(getChannel(id)!.supportsEvent('synology_session_cleared')).toBe(false);
}
});
it('CHREG-009 — registerChannel replaces an existing id rather than duplicating it', () => {
registerChannel(fakeChannel('email', { source: 'builtin' }));
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
});
it('CHREG-010 — a channel that rejects is the caller’s problem, not the registry’s', async () => {
const boom = fakeChannel('plugin:boom', {
sendToUser: async () => {
throw new Error('nope');
},
});
setPluginChannelSource(() => [boom]);
await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope');
// and the registry is unharmed
expect(listChannels()).toHaveLength(5);
});
});