mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
e4f39bc8e7
NotificationsService.send() now reports how many deliveries it attempted and how many went out, so the reminder jobs can tell a failed send from a sent one. WPDISP-008 and WPDISP-011 asserted that the send resolved to undefined, which only meant "no error" and broke on the new result. They now pin the counts: the in-app row and the webhook go out, and a push the broken key pair cannot send is skipped rather than counted as failed.
310 lines
13 KiB
TypeScript
310 lines
13 KiB
TypeScript
/**
|
|
* Web Push inside the dispatcher (WPDISP-*): the admin switch, the per-user
|
|
* Push column, admin-scoped events, the preference matrix, the generic test
|
|
* route, and all of them while the key pair cannot be used, stored or from
|
|
* VAPID_*. Real NotificationsService and real SQL; safeFetchFollow is the edge.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
|
import { createECDH } from 'node:crypto';
|
|
|
|
// One snapshot connection per file, created inside the factory so nothing has
|
|
// to be hoisted above the imports; the tests reach it through the mocked module.
|
|
vi.mock('../../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../../../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
return {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => null,
|
|
isOwner: () => false,
|
|
};
|
|
});
|
|
vi.mock('../../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
const { safeFetchFollow, logError } = vi.hoisted(() => ({ safeFetchFollow: vi.fn(), logError: vi.fn() }));
|
|
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
|
|
LOG_LEVEL: 'error',
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError,
|
|
logWarn: vi.fn(),
|
|
}));
|
|
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
|
vi.mock('../../../../src/utils/ssrfGuard', () => {
|
|
class SsrfBlockedError extends Error {}
|
|
return { SsrfBlockedError, safeFetchFollow };
|
|
});
|
|
|
|
import { db as testDb } from '../../../../src/db/database';
|
|
import { resetTestDb } from '../../../helpers/test-db';
|
|
import { createAdmin, createUser, disableNotificationPref, setNotificationChannels } from '../../../helpers/factories';
|
|
import {
|
|
makeNotificationPreferencesService,
|
|
makeNotificationsService,
|
|
makePushSubscriptionsService,
|
|
makeVapidKeysService,
|
|
} from '../../../helpers/notifications';
|
|
import type { NotificationsService } from '../../../../src/nest/notifications/notifications.service';
|
|
import type { NotificationPreferencesService } from '../../../../src/nest/notifications/notification-preferences.service';
|
|
import type { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
|
|
import {
|
|
PUSH_UNAVAILABLE_ERROR,
|
|
VAPID_PRIVATE_KEY_SETTING,
|
|
type VapidKeysService,
|
|
} from '../../../../src/nest/notifications/push/vapid-keys.service';
|
|
import { checkPushSubscription } from '../../../../src/nest/notifications/push/push-subscription.helpers';
|
|
import { generateVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto';
|
|
|
|
// Built in beforeAll: every provider takes repositories and a UnitOfWork,
|
|
// which are async to resolve on this file's handle.
|
|
let notifications: NotificationsService;
|
|
let prefs: NotificationPreferencesService;
|
|
let subscriptions: PushSubscriptionsService;
|
|
let keys: VapidKeysService;
|
|
|
|
async function addDevice(userId: number, endpoint = `https://fcm.googleapis.com/fcm/send/u${userId}`): Promise<string> {
|
|
const ua = createECDH('prime256v1');
|
|
ua.generateKeys();
|
|
const checked = checkPushSubscription({
|
|
endpoint,
|
|
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 7).toString('base64url') },
|
|
});
|
|
if ('error' in checked) throw new Error(checked.error);
|
|
await subscriptions.upsert(userId, checked.value, await keys.getPublicKey());
|
|
return endpoint;
|
|
}
|
|
|
|
function invite(targetId: number, actorId: number) {
|
|
return notifications.send({
|
|
event: 'trip_invite',
|
|
actorId,
|
|
params: { trip: 'Lisbon', actor: 'bob@example.test', invitee: 'alice', tripId: '12' },
|
|
scope: 'user',
|
|
targetId,
|
|
});
|
|
}
|
|
|
|
const pushedTo = () => safeFetchFollow.mock.calls.map((c) => c[0] as string);
|
|
|
|
/** What a restore under a different ENCRYPTION_KEY leaves behind: a private key that no longer opens. */
|
|
const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
|
|
|
|
function breakStoredPrivateKey(): void {
|
|
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING);
|
|
}
|
|
|
|
const pushColumn = async (userId: number) =>
|
|
(await prefs.getPreferencesMatrix(userId, 'user')).channels.find((c) => c.id === 'push');
|
|
|
|
const storedKeyRows = () =>
|
|
testDb.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'web_push_vapid_%' ORDER BY key").all();
|
|
|
|
beforeAll(async () => {
|
|
notifications = await makeNotificationsService(testDb);
|
|
prefs = await makeNotificationPreferencesService(testDb);
|
|
subscriptions = await makePushSubscriptionsService(testDb);
|
|
keys = await makeVapidKeysService(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
safeFetchFollow.mockReset();
|
|
safeFetchFollow.mockResolvedValue({ ok: true, status: 201, body: null, headers: { get: () => null } });
|
|
logError.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
describe('Web Push in NotificationsService.send()', () => {
|
|
it('WPDISP-001: goes out once the admin enabled push and the user has a device', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: actor } = createUser(testDb);
|
|
const endpoint = await addDevice(user.id);
|
|
setNotificationChannels(testDb, 'push');
|
|
await invite(user.id, actor.id);
|
|
expect(pushedTo()).toEqual([endpoint]);
|
|
});
|
|
|
|
it('WPDISP-002: stays quiet while the admin has not enabled push', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: actor } = createUser(testDb);
|
|
await addDevice(user.id);
|
|
setNotificationChannels(testDb, 'email');
|
|
await invite(user.id, actor.id);
|
|
expect(safeFetchFollow).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('WPDISP-003: respects the user switching the event off in the Push column', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: actor } = createUser(testDb);
|
|
await addDevice(user.id);
|
|
setNotificationChannels(testDb, 'push');
|
|
disableNotificationPref(testDb, user.id, 'trip_invite', 'push');
|
|
await invite(user.id, actor.id);
|
|
expect(safeFetchFollow).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('WPDISP-004: never carries an admin-scoped event', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
await addDevice(admin.id);
|
|
setNotificationChannels(testDb, 'push');
|
|
await notifications.send({
|
|
event: 'version_available',
|
|
actorId: null,
|
|
params: { version: '9.9.9' },
|
|
scope: 'admin',
|
|
targetId: 0,
|
|
});
|
|
expect(safeFetchFollow).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Web Push in the preference matrix', () => {
|
|
it('WPDISP-005: the user matrix gets a Push column, active by the admin switch, configured by a device', async () => {
|
|
const { user } = createUser(testDb);
|
|
let push = (await prefs.getPreferencesMatrix(user.id, 'user')).channels.find((c) => c.id === 'push');
|
|
expect(push).toMatchObject({
|
|
source: 'builtin',
|
|
labelKey: 'settings.notificationPreferences.push',
|
|
active: false,
|
|
configured: false,
|
|
});
|
|
|
|
setNotificationChannels(testDb, 'email,push');
|
|
await addDevice(user.id);
|
|
push = (await prefs.getPreferencesMatrix(user.id, 'user')).channels.find((c) => c.id === 'push');
|
|
expect(push).toMatchObject({ active: true, configured: true });
|
|
expect(await prefs.getActiveChannels()).toEqual(['email', 'push']);
|
|
expect((await prefs.getPreferencesMatrix(user.id, 'user')).implemented_combos['trip_invite']).toContain('push');
|
|
});
|
|
|
|
it('WPDISP-006: the admin matrix has no Push column, since push never carries admin events', async () => {
|
|
const { user: admin } = createAdmin(testDb);
|
|
const matrix = await prefs.getPreferencesMatrix(admin.id, 'admin', 'admin');
|
|
expect(matrix.channels.map((c) => c.id)).toEqual(['inapp', 'email', 'webhook', 'ntfy']);
|
|
expect(matrix.implemented_combos['version_available']).not.toContain('push');
|
|
expect(matrix.preferences['version_available']).not.toHaveProperty('push');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/notifications/test/push, through the registry', () => {
|
|
it('WPDISP-007: refuses a user without a device and sends to one with', async () => {
|
|
const { user } = createUser(testDb);
|
|
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({
|
|
success: false,
|
|
error: 'Channel is not configured for this user',
|
|
});
|
|
const endpoint = await addDevice(user.id);
|
|
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({ success: true });
|
|
expect(pushedTo()).toEqual([endpoint]);
|
|
});
|
|
});
|
|
|
|
describe('Web Push while the stored key pair cannot be used', () => {
|
|
it('WPDISP-008: a send skips push without an error while in-app and webhook still go out', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: actor } = createUser(testDb);
|
|
const endpoint = await addDevice(user.id);
|
|
testDb
|
|
.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)")
|
|
.run(user.id, 'https://hooks.example.test/trek');
|
|
setNotificationChannels(testDb, 'webhook,push');
|
|
breakStoredPrivateKey();
|
|
|
|
// In-app and the webhook go out; the skipped push is no failed delivery.
|
|
await expect(invite(user.id, actor.id)).resolves.toEqual({ attempted: 2, delivered: 2 });
|
|
|
|
expect(pushedTo()).toEqual(['https://hooks.example.test/trek']);
|
|
const inApp = testDb.prepare('SELECT COUNT(*) AS n FROM notifications WHERE recipient_id = ?').get(user.id) as {
|
|
n: number;
|
|
};
|
|
expect(inApp.n).toBe(1);
|
|
// The device is kept for when the key is back, and nothing failed on the way.
|
|
expect((await subscriptions.listForUser(user.id)).map((r) => r.endpoint)).toEqual([endpoint]);
|
|
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('dispatch failed'));
|
|
expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('Web Push failed'));
|
|
});
|
|
|
|
it('WPDISP-009: the matrix stops offering push, so the card and the column go, and both come back with the key', async () => {
|
|
const { user } = createUser(testDb);
|
|
setNotificationChannels(testDb, 'email,push');
|
|
await addDevice(user.id);
|
|
const ciphertext = (
|
|
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PRIVATE_KEY_SETTING) as { value: string }
|
|
).value;
|
|
breakStoredPrivateKey();
|
|
|
|
expect(await pushColumn(user.id)).toMatchObject({ active: false, configured: true });
|
|
// Only push: email keeps its column, SMTP or not, as it always has.
|
|
expect((await prefs.getPreferencesMatrix(user.id, 'user')).channels.find((c) => c.id === 'email')?.active).toBe(true);
|
|
|
|
testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING);
|
|
expect(await pushColumn(user.id)).toMatchObject({ active: true, configured: true });
|
|
});
|
|
|
|
it('WPDISP-009b: reading the matrix while neither key row exists writes nothing, and push is still offered', async () => {
|
|
const { user } = createUser(testDb);
|
|
setNotificationChannels(testDb, 'email,push');
|
|
await addDevice(user.id);
|
|
// What deleting both rows to start over leaves under a running server.
|
|
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'web_push_vapid_%'").run();
|
|
|
|
expect(await pushColumn(user.id)).toMatchObject({ active: true, configured: true });
|
|
expect(storedKeyRows()).toEqual([]);
|
|
});
|
|
|
|
it('WPDISP-010: the test send says push is unavailable and sends nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
await addDevice(user.id);
|
|
breakStoredPrivateKey();
|
|
await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({
|
|
success: false,
|
|
error: PUSH_UNAVAILABLE_ERROR,
|
|
});
|
|
expect(safeFetchFollow).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Web Push while the VAPID_* pair is broken and an older pair is stored', () => {
|
|
it('WPDISP-011: nothing is sent, the device and both rows stay, the column goes, and all of it returns with the variables', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: actor } = createUser(testDb);
|
|
setNotificationChannels(testDb, 'push');
|
|
// The first start without VAPID_* stored a pair; the operator then brought their own.
|
|
await keys.getPublicKey();
|
|
const stored = storedKeyRows();
|
|
const pair = generateVapidKeyPair();
|
|
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
|
|
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
|
const endpoint = await addDevice(user.id);
|
|
// A typo or a rotated Secret: still a valid 32-byte key, so boot is fine.
|
|
vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey);
|
|
|
|
// Only the in-app row goes out; the push it cannot send counts as skipped, not failed.
|
|
await expect(invite(user.id, actor.id)).resolves.toEqual({ attempted: 1, delivered: 1 });
|
|
|
|
expect(safeFetchFollow).not.toHaveBeenCalled();
|
|
expect((await subscriptions.listForUser(user.id)).map((r) => [r.endpoint, r.vapid_public_key])).toEqual([
|
|
[endpoint, pair.publicKey],
|
|
]);
|
|
expect(storedKeyRows()).toEqual(stored);
|
|
expect(await pushColumn(user.id)).toMatchObject({ active: false, configured: true });
|
|
|
|
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
|
|
expect(await pushColumn(user.id)).toMatchObject({ active: true, configured: true });
|
|
await invite(user.id, actor.id);
|
|
expect(pushedTo()).toEqual([endpoint]);
|
|
});
|
|
});
|