mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
272 lines
11 KiB
TypeScript
272 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 { db as testDb } from '../../../../src/db/database';
|
|
import { PushSubscriptions } from '../../../../src/db/entities/PushSubscriptions.entity';
|
|
import { Users } from '../../../../src/db/entities/Users.entity';
|
|
import {
|
|
PUSH_SUBSCRIPTION_ERRORS,
|
|
checkPushEndpoint,
|
|
checkPushSubscription,
|
|
isPushServiceEndpoint,
|
|
type CheckedPushSubscription,
|
|
} from '../../../../src/nest/notifications/push/push-subscription.helpers';
|
|
import {
|
|
MAX_PUSH_DEVICES_PER_USER,
|
|
PushSubscriptionsService,
|
|
} from '../../../../src/nest/notifications/push/push-subscriptions.service';
|
|
import { createUser } from '../../../helpers/factories';
|
|
import { countRows, deleteRows, updateRows } from '../../../helpers/factories/rows';
|
|
import { makePushSubscriptionsService } from '../../../helpers/notifications';
|
|
import { resetTestDb } from '../../../helpers/test-db';
|
|
import { sharedTestOrm } from '../../../helpers/test-uow';
|
|
import { MAX_PUSH_ENDPOINT_LENGTH, pushUnsubscribeRequestSchema } from '@trek/shared';
|
|
|
|
import { createECDH } from 'node:crypto';
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
// 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,
|
|
};
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|