Files
TREK/server/tests/unit/nest/notifications/push.controller.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

117 lines
4.8 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.
/**
* PushController parity (PUSHCTL-*): status codes, bodies and the bespoke 400
* strings of /api/notifications/push, over stubbed providers.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
import { createECDH } from 'node:crypto';
import { PushController } from '../../../../src/nest/notifications/push/push.controller';
import { PUSH_SUBSCRIPTION_ERRORS } from '../../../../src/nest/notifications/push/push-subscription.helpers';
import type { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
import {
PUSH_UNAVAILABLE_ERROR,
PushUnavailableError,
type VapidKeysService,
} from '../../../../src/nest/notifications/push/vapid-keys.service';
import type { User } from '../../../../src/types';
const keys = { getPublicKey: vi.fn((): string => 'server-public-key') };
const subscriptions = { upsert: vi.fn(() => 2), removeForUser: vi.fn(() => true) };
const controller = new PushController(
keys as unknown as VapidKeysService,
subscriptions as unknown as PushSubscriptionsService,
);
const user = { id: 5 } as User;
function subscription(endpoint = 'https://fcm.googleapis.com/fcm/send/abc') {
const ua = createECDH('prime256v1');
ua.generateKeys();
return {
endpoint,
expirationTime: null,
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 1).toString('base64url') },
};
}
function thrown(fn: () => unknown): HttpException {
try {
fn();
} catch (err) {
return err as HttpException;
}
throw new Error('expected a throw');
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('PushController', () => {
it('PUSHCTL-001: GET public-key answers the key browsers subscribe with', () => {
expect(controller.publicKey()).toEqual({ publicKey: 'server-public-key' });
});
it('PUSHCTL-002: POST subscriptions stores the checked subscription against the current key and answers 200', () => {
const sub = subscription();
expect(controller.subscribe(user, { subscription: sub }, 'Firefox/130')).toEqual({ success: true, devices: 2 });
expect(subscriptions.upsert).toHaveBeenCalledWith(
5,
{ endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth },
'server-public-key',
'Firefox/130',
);
expect(Reflect.getMetadata(HTTP_CODE_METADATA, PushController.prototype.subscribe)).toBe(200);
});
it('PUSHCTL-003: POST subscriptions answers the bespoke 400 and stores nothing', () => {
const err = thrown(() => controller.subscribe(user, { subscription: subscription('https://example.com/collect') }));
expect(err).toBeInstanceOf(HttpException);
expect(err.getStatus()).toBe(400);
expect(err.getResponse()).toEqual({ error: PUSH_SUBSCRIPTION_ERRORS.service });
expect(subscriptions.upsert).not.toHaveBeenCalled();
});
it('PUSHCTL-004: DELETE subscriptions removes only the caller’s endpoint and is idempotent', () => {
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc' })).toEqual({
success: true,
});
expect(subscriptions.removeForUser).toHaveBeenCalledWith(5, 'https://fcm.googleapis.com/fcm/send/abc');
subscriptions.removeForUser.mockReturnValueOnce(false);
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/gone' })).toEqual({
success: true,
});
expect(Reflect.getMetadata(HTTP_CODE_METADATA, PushController.prototype.unsubscribe)).toBe(200);
});
it('PUSHCTL-005: while the stored key pair is unusable, public-key and subscribe answer 503 and store nothing', () => {
keys.getPublicKey.mockImplementation(() => {
throw new PushUnavailableError();
});
try {
const fromKey = thrown(() => controller.publicKey());
expect(fromKey.getStatus()).toBe(503);
expect(fromKey.getResponse()).toEqual({ error: PUSH_UNAVAILABLE_ERROR });
const fromSubscribe = thrown(() => controller.subscribe(user, { subscription: subscription() }));
expect(fromSubscribe.getStatus()).toBe(503);
expect(fromSubscribe.getResponse()).toEqual({ error: PUSH_UNAVAILABLE_ERROR });
expect(subscriptions.upsert).not.toHaveBeenCalled();
// A device can still be forgotten.
expect(controller.unsubscribe(user, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc' })).toEqual({
success: true,
});
} finally {
keys.getPublicKey.mockImplementation(() => 'server-public-key');
}
});
it('PUSHCTL-006: any other key failure is not dressed up as a 503', () => {
keys.getPublicKey.mockImplementationOnce(() => {
throw new Error('database is locked');
});
expect(() => controller.publicKey()).toThrow('database is locked');
});
});