Files
TREK/server/tests/unit/nest/notifications/push-subscriptions.service.test.ts
T
Maurice 8eaeed07a8 test(server): seed the smaller service specs through the repositories
The service suites for mail, notification channels and transports,
idempotency, invites, tokens, OAuth, push subscriptions, the version
check, budget settlement maths, addons, place shadow picks, tour GPX
import, the place region repair, journey stats, day notes and storage
jobs seeded and inspected their rows with raw statements. They now go
through the ORM factories and readers over the suite's shared test ORM.

The database mocks of day notes and OAuth also lose their dead
canAccessTrip and isOwner stubs: db/database exports neither since the
repositories took them over.

Raw statements stay, marked, where they are the subject: the backup
file the boot restore reads before any ORM exists, the full raw row a
day note is checked against, sqlite_master, the synchronous probe that
counts tours while the GPX import may still hold its transaction, and
the edits the region repair case lands mid-pass, past the ORM's
connection queue.
2026-10-08 18:45:56 +02:00

277 lines
11 KiB
TypeScript

/**
* Web Push subscriptions: what the browser may hand over (PUSHSUB-CHK-*) and
* the table behind it (PUSHSUB-*), on the migrated snapshot including legacy step 245.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { createECDH } from 'node:crypto';
import { MAX_PUSH_ENDPOINT_LENGTH, pushUnsubscribeRequestSchema } from '@trek/shared';
// 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: () => {},
}));
import { db as testDb } from '../../../../src/db/database';
import { resetTestDb } from '../../../helpers/test-db';
import { createUser } from '../../../helpers/factories';
import { makePushSubscriptionsService } from '../../../helpers/notifications';
import { sharedTestOrm } from '../../../helpers/test-uow';
import { countRows, deleteRows, updateRows } from '../../../helpers/factories/rows';
import { PushSubscriptions } from '../../../../src/db/entities/PushSubscriptions.entity';
import { Users } from '../../../../src/db/entities/Users.entity';
import {
MAX_PUSH_DEVICES_PER_USER,
PushSubscriptionsService,
} from '../../../../src/nest/notifications/push/push-subscriptions.service';
import {
PUSH_SUBSCRIPTION_ERRORS,
checkPushEndpoint,
checkPushSubscription,
isPushServiceEndpoint,
type CheckedPushSubscription,
} from '../../../../src/nest/notifications/push/push-subscription.helpers';
let subs: PushSubscriptionsService;
function browserKeys() {
const ua = createECDH('prime256v1');
ua.generateKeys();
return { p256dh: ua.getPublicKey('base64url'), auth: Buffer.from('0123456789abcdef').toString('base64url') };
}
function checked(endpoint: string): CheckedPushSubscription {
const result = checkPushSubscription({ endpoint, keys: browserKeys() });
if ('error' in result) throw new Error(result.error);
return result.value;
}
const FCM = 'https://fcm.googleapis.com/fcm/send/device-1';
beforeAll(async () => {
subs = await makePushSubscriptionsService(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
describe('checkPushEndpoint / checkPushSubscription', () => {
it('PUSHSUB-CHK-001: accepts the push services browsers use', () => {
for (const endpoint of [
FCM,
'https://android.googleapis.com/gcm/send/x',
'https://updates.push.services.mozilla.com/wpush/v2/gAAAA',
'https://push.services.mozilla.com/wpush/v1/x',
'https://web.push.apple.com/QGx9',
'https://api.push.apple.com/3/device/x',
'https://wns2-par02p.notify.windows.com/w/?token=BQYAAA',
]) {
expect(checkPushEndpoint(endpoint), endpoint).toBe('ok');
expect(isPushServiceEndpoint(endpoint)).toBe(true);
}
});
it('PUSHSUB-CHK-002: refuses anything that is not a plain https URL', () => {
for (const endpoint of [
'not a url',
'http://fcm.googleapis.com/fcm/send/x',
'https://user:pw@fcm.googleapis.com/fcm/send/x',
'https://fcm.googleapis.com:8443/fcm/send/x',
`https://fcm.googleapis.com/${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH)}`,
]) {
expect(checkPushEndpoint(endpoint), endpoint).toBe('endpoint');
}
});
it('PUSHSUB-CHK-002b: takes an endpoint exactly as long as the contract allows, and not one character more', () => {
const base = 'https://fcm.googleapis.com/fcm/send/';
const longest = `${base}${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH - base.length)}`;
// The contract and the check share one limit, so a device that could register can also be sent to and removed.
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: longest }).success).toBe(true);
expect(checkPushEndpoint(longest)).toBe('ok');
expect(checkPushEndpoint(`${longest}a`)).toBe('endpoint');
});
it('PUSHSUB-CHK-003: refuses hosts that are not push services, including lookalikes', () => {
for (const endpoint of [
'https://example.com/push',
'https://127.0.0.1/push',
'https://fcm.googleapis.com.evil.test/x',
'https://evilnotify.windows.com/x',
'https://.notify.windows.com/x',
'https://push.apple.com/x',
]) {
expect(checkPushEndpoint(endpoint), endpoint).not.toBe('ok');
}
expect(checkPushSubscription({ endpoint: 'https://example.com/push', keys: browserKeys() })).toEqual({
ok: false,
error: PUSH_SUBSCRIPTION_ERRORS.service,
});
expect(checkPushSubscription({ endpoint: 'http://fcm.googleapis.com/x', keys: browserKeys() })).toEqual({
ok: false,
error: PUSH_SUBSCRIPTION_ERRORS.endpoint,
});
});
it('PUSHSUB-CHK-004: wants a p256dh on the curve and a 16-byte auth secret', () => {
const keys = browserKeys();
const offCurve = Buffer.from(keys.p256dh, 'base64url');
offCurve[64] ^= 0x01;
for (const p256dh of [
'!!!',
Buffer.alloc(65, 4).toString('base64url'),
offCurve.toString('base64url'),
keys.auth,
]) {
expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, p256dh } })).toEqual({
ok: false,
error: PUSH_SUBSCRIPTION_ERRORS.p256dh,
});
}
for (const auth of ['!!!', Buffer.alloc(8).toString('base64url')]) {
expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, auth } })).toEqual({
ok: false,
error: PUSH_SUBSCRIPTION_ERRORS.auth,
});
}
});
it('PUSHSUB-CHK-005: hands the keys back canonical, without padding', () => {
const keys = browserKeys();
const result = checkPushSubscription({
endpoint: FCM,
keys: { p256dh: `${keys.p256dh}=`, auth: `${keys.auth}==` },
});
expect(result).toEqual({ ok: true, value: { endpoint: FCM, p256dh: keys.p256dh, auth: keys.auth } });
});
});
describe('PushSubscriptionsService', () => {
it('PUSHSUB-001: stores a subscription with the key it was made for and counts devices', async () => {
const { user } = createUser(testDb);
const sub = checked(FCM);
expect(await subs.hasAny(user.id)).toBe(false);
expect(await subs.upsert(user.id, sub, 'server-key', 'Mozilla/5.0')).toBe(1);
const [row] = await subs.listForUser(user.id);
expect(row).toMatchObject({
user_id: user.id,
endpoint: FCM,
p256dh: sub.p256dh,
auth: sub.auth,
vapid_public_key: 'server-key',
user_agent: 'Mozilla/5.0',
failure_count: 0,
last_success_at: null,
});
expect(await subs.hasAny(user.id)).toBe(true);
expect(await subs.countForUser(user.id)).toBe(1);
});
it('PUSHSUB-002: subscribing again refreshes the same row instead of adding one', async () => {
const { user } = createUser(testDb);
await subs.upsert(user.id, checked(FCM), 'old-key');
const [before] = await subs.listForUser(user.id);
await subs.recordFailure(before.id);
const fresh = checked(FCM);
expect(await subs.upsert(user.id, fresh, 'new-key', null)).toBe(1);
const [after] = await subs.listForUser(user.id);
expect(after.id).toBe(before.id);
expect(after).toMatchObject({
p256dh: fresh.p256dh,
vapid_public_key: 'new-key',
failure_count: 0,
user_agent: null,
});
});
it('PUSHSUB-003: a browser shared by two accounts belongs to whoever subscribed on it last', async () => {
const { user: first } = createUser(testDb);
const { user: second } = createUser(testDb);
await subs.upsert(first.id, checked(FCM), 'k');
expect(await subs.upsert(second.id, checked(FCM), 'k')).toBe(1);
expect(await subs.countForUser(first.id)).toBe(0);
expect((await subs.listForUser(second.id)).map((r) => r.endpoint)).toEqual([FCM]);
});
it('PUSHSUB-004: caps the devices per user and drops the one registered longest ago', async () => {
const { user } = createUser(testDb);
for (let i = 0; i < MAX_PUSH_DEVICES_PER_USER; i++) {
await subs.upsert(user.id, checked(`https://fcm.googleapis.com/fcm/send/d${i}`), 'k');
}
// Same second for every row; the id breaks the tie, so d0 is the oldest.
// d0 subscribing again renews it, which leaves d1 as the oldest.
await updateRows(await sharedTestOrm(testDb), PushSubscriptions, {}, { created_at: '2026-01-01 00:00:00' });
await subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/d0'), 'k');
expect(await subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/new'), 'k')).toBe(
MAX_PUSH_DEVICES_PER_USER,
);
const endpoints = (await subs.listForUser(user.id)).map((r) => r.endpoint);
expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/d0');
expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/new');
expect(endpoints).not.toContain('https://fcm.googleapis.com/fcm/send/d1');
});
it('PUSHSUB-005: a user can only remove their own endpoint', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
await subs.upsert(owner.id, checked(FCM), 'k');
expect(await subs.removeForUser(other.id, FCM)).toBe(false);
expect(await subs.countForUser(owner.id)).toBe(1);
expect(await subs.removeForUser(owner.id, FCM)).toBe(true);
expect(await subs.countForUser(owner.id)).toBe(0);
});
it('PUSHSUB-006: success resets the failure count, failures add up, deleteById removes the row', async () => {
const { user } = createUser(testDb);
await subs.upsert(user.id, checked(FCM), 'k');
const [row] = await subs.listForUser(user.id);
expect(await subs.recordFailure(row.id)).toBe(1);
expect(await subs.recordFailure(row.id)).toBe(2);
expect((await subs.listForUser(user.id))[0].failure_count).toBe(2);
await subs.recordSuccess(row.id);
const [after] = await subs.listForUser(user.id);
expect(after.failure_count).toBe(0);
expect(after.last_success_at).not.toBeNull();
expect(await subs.recordFailure(row.id)).toBe(1);
await subs.deleteById(row.id);
expect(await subs.hasAny(user.id)).toBe(false);
// A failure recorded for a row another send already removed changes nothing.
expect(await subs.recordFailure(row.id)).toBe(0);
});
it('PUSHSUB-007: cuts a long user agent and the rows go with the user', async () => {
const { user } = createUser(testDb);
await subs.upsert(user.id, checked(FCM), 'k', 'x'.repeat(1000));
expect((await subs.listForUser(user.id))[0].user_agent).toHaveLength(256);
await deleteRows(await sharedTestOrm(testDb), Users, { id: user.id });
expect(await countRows(await sharedTestOrm(testDb), PushSubscriptions)).toBe(0);
});
it('PUSHSUB-008: migration 245 created the table with its user index', async () => {
const index = testDb
// test-sql-allow: the index list comes from sqlite_master, which no entity or repository maps.
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'push_subscriptions'")
.all() as { name: string }[];
expect(index.map((i) => i.name)).toContain('idx_push_subscriptions_user');
});
});