Files
TREK/server/tests/unit/nest/channel-override.test.ts
T
Maurice ac96644508 refactor(server): fold the notifications domain out of services/
Four files taken apart rather than moved, because they only worked as a knot.

The mailer becomes its own leaf module. AuthService sends the password-reset
mail while NotificationsModule imports AuthModule for the demo gate in
NotificationsMcp - a mailer inside the notifications domain would have closed a
hard cycle that only a forwardRef could open again. Webhook and ntfy stay with
notifications; auth has no business importing them.

The channel registry stays a module singleton and does NOT become a provider.
PluginRuntimeService pushes its channel getter in at onModuleInit, and
notifications.bridge.ts builds its own NotificationsService for the scheduler
crons and the memories senders. A provider would hand the bridge a second, empty
registry - every plugin channel would then go quiet with no error anywhere.
CHOVR-015 drives that seam end to end: a channel published through the runtime,
delivered through the bridge. Nothing pinned it before; the existing cases all
set the source themselves.

The built-ins no longer self-register by side-effect import. They used to exist
only because notificationPreferencesService happened to `import
'./notifications/builtins'`; removing that line would have silenced email,
webhook and ntfy without a failure. They are built from the injected transports
and registered from NotificationsService's constructor, so both instances have
them.

No cached nodemailer transport: MailerService.createTransport re-reads the SMTP
config and skipTlsVerify per send, exactly as the three inline call sites did,
so an admin changing them does not have to restart.

Preferences become a provider over DatabaseService. Eight places built
NotificationsService by hand, so a tests/helpers/notifications.ts factory keeps
the next constructor parameter a one-file diff instead of an eight-file one.
2026-08-08 12:09:56 +02:00

258 lines
12 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.
/**
* A plugin channel may only ever ADD a channel — never override or shadow a built-in.
*
* The runtime mints plugin ids with pluginChannelId() and leaves the built-in-only
* privileges off, so none of this is reachable through a manifest. These tests pin the
* REGISTRY's own enforcement, so the guarantee survives a bug or a future caller that
* hands the registry a channel it shouldn't. Without it, a channel claiming `email`
* rides the user's email opt-in and receives admin-scoped notifications.
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null } };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key: (v: string) => v, maybe_encrypt_api_key: (v: string) => v, encrypt_api_key: (v: string) => v }));
const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn().mockResolvedValue({ accepted: ['a@b.c'] }) }));
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: sendMailMock, verify: vi.fn() })) } }));
vi.stubGlobal('fetch', vi.fn());
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })), createPinnedDispatcher: vi.fn(() => ({})) }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, setAppSetting, setNotificationChannels } from '../../helpers/factories';
// The send dispatcher is DI-native since the notifications fold; drive it
// through the same outside-container entry point the scheduler uses.
import { send } from '../../../src/nest/notifications/notifications.bridge';
import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { createPluginRuntime } from '../../helpers/plugin-host';
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service';
import {
setPluginChannelSource,
listChannels,
getChannel,
registerChannel,
type ExternalChannel,
} from '../../../src/nest/notifications/channel-registry';
const prefsDbs = new DatabaseService(testDb);
const prefsSvc = new NotificationPreferencesService(prefsDbs, new MailerService(prefsDbs));
const getPreferencesMatrix = prefsSvc.getPreferencesMatrix.bind(prefsSvc);
void WebhookService; void NtfyService;
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeEach(() => { resetTestDb(testDb); setPluginChannelSource(null); });
const rogueSend = vi.fn().mockResolvedValue(true);
const rogueGlobal = vi.fn().mockResolvedValue(true);
function setSmtp(): void {
setAppSetting(testDb, 'smtp_host', 'mail.test.com');
setAppSetting(testDb, 'smtp_port', '587');
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
}
/** A channel claiming a BUILT-IN id plus the built-in-only privilege flags. */
function rogue(over: Partial<ExternalChannel> = {}): ExternalChannel {
return {
id: 'email',
source: 'builtin',
bypassesActiveToggleForAdminEvents: true,
supportsAdminGlobal: true,
supportsEvent: () => true,
isConfiguredFor: () => true,
sendToUser: rogueSend,
sendGlobal: rogueGlobal,
...over,
} as ExternalChannel;
}
const TRIP_INVITE = { event: 'trip_invite', actorId: null, scope: 'user', targetId: 0, params: { trip: 'Rome', actor: 'A', invitee: 'B', tripId: '1' } } as const;
describe('a plugin channel can never override a built-in', () => {
beforeEach(() => { rogueSend.mockClear(); rogueGlobal.mockClear(); });
it('CHOVR-001 — a channel claiming a built-in id is dropped from the registry', () => {
setPluginChannelSource(() => [rogue()]);
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
expect(getChannel('email')!.source).toBe('builtin');
});
it('CHOVR-002 — it does NOT ride the user’s email opt-in', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'email'); // the user enabled EMAIL, not a plugin
setPluginChannelSource(() => [rogue()]);
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).not.toHaveBeenCalled();
expect(sendMailMock).toHaveBeenCalledTimes(1); // the real email channel still delivers
});
it('CHOVR-003 — an un-namespaced id is dropped even when it collides with nothing', () => {
setPluginChannelSource(() => [rogue({ id: 'carrier-pigeon', source: 'plugin' })]);
expect(listChannels().map(c => c.id)).not.toContain('carrier-pigeon');
});
it('CHOVR-004 — a properly namespaced channel IS admitted', () => {
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]);
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
});
it('CHOVR-005 — an admitted plugin channel has its built-in-only privileges stripped', () => {
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' })]);
const ch = getChannel('plugin:gotify')!;
expect(ch.source).toBe('plugin'); // it cannot self-declare as a built-in
expect(ch.bypassesActiveToggleForAdminEvents).toBe(false);
expect(ch.supportsAdminGlobal).toBe(false);
expect(ch.sendGlobal).toBeUndefined();
});
it('CHOVR-006 — a plugin channel gets NO admin-scoped event, even claiming to support one', async () => {
createAdmin(testDb);
setSmtp();
setNotificationChannels(testDb, 'plugin:gotify');
// supportsEvent lies and says yes to everything; the registry strips what makes it matter.
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', supportsEvent: () => true })]);
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
expect(rogueSend).not.toHaveBeenCalled();
expect(rogueGlobal).not.toHaveBeenCalled();
});
it('CHOVR-007 — duplicate plugin ids collapse to one (no double send, no double column)', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'plugin:gotify');
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' }), rogue({ id: 'plugin:gotify' })]);
expect(listChannels().filter(c => c.id === 'plugin:gotify')).toHaveLength(1);
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).toHaveBeenCalledTimes(1);
});
it('CHOVR-008 — the preferences matrix never shows a duplicate column', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email');
setPluginChannelSource(() => [rogue()]);
const ids = getPreferencesMatrix(user.id, 'user').channels.map(c => c.id);
expect(ids.filter(i => i === 'email')).toHaveLength(1);
expect(new Set(ids).size).toBe(ids.length);
});
it('CHOVR-009 — a built-in may not claim the plugin namespace either', () => {
expect(() => registerChannel(rogue({ id: 'plugin:sneaky' }))).toThrow(/plugin namespace/);
});
});
describe('a live plugin channel needs no second opt-in', () => {
beforeEach(() => { rogueSend.mockClear(); });
it('CHOVR-011 — it appears in the matrix with NOTHING in notification_channels', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none'); // the admin has enabled no built-in at all
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', label: 'Gotify' })]);
const matrix = getPreferencesMatrix(user.id, 'user');
const ch = matrix.channels.find(c => c.id === 'plugin:gotify')!;
// Enabling the PLUGIN is the opt-in. There is no UI that can write a `plugin:` id into
// the notification_channels CSV, so requiring one meant the channel could never show.
expect(ch.active).toBe(true);
expect(ch.label).toBe('Gotify');
expect(matrix.implemented_combos['trip_invite']).toContain('plugin:gotify');
});
it('CHOVR-012 — and it actually DELIVERS with nothing in notification_channels', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]);
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).toHaveBeenCalledTimes(1);
});
it('CHOVR-013 — a BUILT-IN still needs its explicit switch', async () => {
const { user } = createUser(testDb);
setSmtp();
setNotificationChannels(testDb, 'none'); // email NOT enabled
sendMailMock.mockClear();
await send({ ...TRIP_INVITE, targetId: user.id });
expect(sendMailMock).not.toHaveBeenCalled();
});
it('CHOVR-014 — an unconfigured user sees the column but gets no delivery', async () => {
const { user } = createUser(testDb);
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', isConfiguredFor: () => false })]);
const ch = getPreferencesMatrix(user.id, 'user').channels.find(c => c.id === 'plugin:gotify')!;
expect(ch.active).toBe(true); // shown — so they can see it and go configure it
expect(ch.configured).toBe(false); // …but flagged as needing setup
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).not.toHaveBeenCalled(); // no credentials → nothing to send with
});
});
/**
* The seam itself, end to end.
*
* PluginRuntimeService pushes its channel getter into the registry at
* onModuleInit; notifications.bridge.ts constructs its own NotificationsService
* for the scheduler crons and the memories senders. Both only meet because the
* registry is a module singleton. Make it a provider and the bridge gets an
* empty one — plugin channels then go quiet with no error anywhere, which is
* exactly the kind of failure nothing else here would catch. Every case above
* sets the source by hand; this one goes through the runtime.
*/
describe('the plugin channel source reaches the bridge instance', () => {
it('CHOVR-015 — a channel the runtime publishes is delivered by notifications.bridge.send', async () => {
const { user } = createUser(testDb, { username: 'recipient' });
setNotificationChannels(testDb, 'none');
const delivered: Array<{ userId: number; title: string }> = [];
const runtime = createPluginRuntime(new DatabaseService(testDb));
// Stand in for a booted supervisor: one plugin providing the hook, and an
// invokeHook that records instead of forking a child.
Object.defineProperty(runtime, 'supervisor', {
value: { providersOf: (hook: string) => (hook === 'notificationChannel' ? ['gotify'] : []) },
configurable: true,
});
(runtime as unknown as { invokeHook: unknown }).invokeHook = async (
_id: string,
_hook: string,
_method: string,
args: unknown[],
) => {
const msg = args[0] as { title: string };
delivered.push({ userId: user.id, title: msg.title });
return true;
};
testDb.prepare("INSERT INTO plugins (id, name, version, status, enabled) VALUES ('gotify', 'Gotify', '1.0.0', 'active', 1)").run();
// The exact line plugin-runtime.service.ts runs in onModuleInit.
setPluginChannelSource(() => runtime.notificationChannels());
try {
expect(getChannel('plugin:gotify')).toBeDefined();
await send({ ...TRIP_INVITE, targetId: user.id });
expect(delivered).toHaveLength(1);
} finally {
setPluginChannelSource(null);
}
});
});