Files
Maurice 724c88c201 style: format server and shared with prettier
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.
2026-10-10 21:23:37 +02:00

531 lines
24 KiB
TypeScript
Raw Permalink 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.
/**
* The Web Push transport (WPUSH-*): what goes over the wire, what each push
* service answer does to the table, and the payload the worker receives
* (WPUSH-PAY-*). safeFetchFollow is the boundary; the database is real.
*/
import { db as testDb } from '../../../../src/db/database';
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
import { PushSubscriptions } from '../../../../src/db/entities/PushSubscriptions.entity';
import { ALL_EVENT_TYPES, type ChannelMessage } from '../../../../src/nest/notifications/notification-events';
import { checkPushSubscription } from '../../../../src/nest/notifications/push/push-subscription.helpers';
import type { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service';
import { PushController } from '../../../../src/nest/notifications/push/push.controller';
import {
PUSH_UNAVAILABLE_ERROR,
VAPID_PRIVATE_KEY_SETTING,
VAPID_PUBLIC_KEY_SETTING,
type VapidKeysService,
} from '../../../../src/nest/notifications/push/vapid-keys.service';
import { generateVapidKeyPair } from '../../../../src/nest/notifications/push/web-push-crypto';
import {
FORBIDDEN_FAILURES_BEFORE_REMOVAL,
MAX_PUSH_PAYLOAD_BYTES,
WebPushService,
buildPushPayload,
samePagePath,
} from '../../../../src/nest/notifications/transports/web-push.service';
import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard';
import { createUser } from '../../../helpers/factories';
import { findRows, updateRows } from '../../../helpers/factories/rows';
import { readAppSetting } from '../../../helpers/factories/settings';
import { makePushSubscriptionsService, makeVapidKeysService } from '../../../helpers/notifications';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { HttpException } from '@nestjs/common';
import { createDecipheriv, createECDH, hkdfSync, type ECDH } 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,
};
});
const { logInfo, logError, safeFetchFollow } = vi.hoisted(() => ({
logInfo: vi.fn(),
logError: vi.fn(),
safeFetchFollow: vi.fn(),
}));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo,
logDebug: vi.fn(),
logError,
logWarn: vi.fn(),
}));
vi.mock('../../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
return { SsrfBlockedError, safeFetchFollow };
});
// Built in beforeAll: both providers take repositories and a UnitOfWork,
// which are async to resolve on this file's handle.
let keys: VapidKeysService;
let subscriptions: PushSubscriptionsService;
let push: WebPushService;
const MSG: ChannelMessage = {
event: 'collab_message',
title: 'New message in "Lisbon"',
body: 'alice: see you at the station',
navigateTarget: '/trips/12',
url: 'https://trek.example.test/trips/12',
};
interface Device {
endpoint: string;
ua: ECDH;
auth: Buffer;
}
async function subscribe(userId: number, endpoint: string): Promise<Device> {
const ua = createECDH('prime256v1');
ua.generateKeys();
const auth = Buffer.from('fedcba9876543210');
const checked = checkPushSubscription({
endpoint,
keys: { p256dh: ua.getPublicKey('base64url'), auth: auth.toString('base64url') },
});
if ('error' in checked) throw new Error(checked.error);
await subscriptions.upsert(userId, checked.value, await keys.getPublicKey());
return { endpoint, ua, auth };
}
/** The browser's side of RFC 8291, to read what was actually sent. */
function open(body: Uint8Array, device: Device): unknown {
const buf = Buffer.from(body);
const salt = buf.subarray(0, 16);
const keyId = buf.subarray(21, 21 + buf[20]);
const record = buf.subarray(21 + buf[20]);
const secret = device.ua.computeSecret(keyId);
const info = Buffer.concat([Buffer.from('WebPush: info\0'), device.ua.getPublicKey(), keyId]);
const ikm = Buffer.from(hkdfSync('sha256', secret, device.auth, info, 32));
const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16));
const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12));
const decipher = createDecipheriv('aes-128-gcm', cek, nonce);
decipher.setAuthTag(record.subarray(record.length - 16));
const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
return JSON.parse(padded.subarray(0, padded.lastIndexOf(0x02)).toString('utf8'));
}
function reply(status: number, text = '') {
return {
ok: status >= 200 && status < 300,
status,
body: null,
headers: { get: () => null },
text: async () => text,
};
}
async function rows(userId: number) {
return await subscriptions.listForUser(userId);
}
beforeAll(async () => {
keys = await makeVapidKeysService(testDb);
subscriptions = await makePushSubscriptionsService(testDb);
push = new WebPushService(keys, subscriptions);
});
beforeEach(() => {
resetTestDb(testDb);
safeFetchFollow.mockReset();
logInfo.mockClear();
logError.mockClear();
});
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
describe('WebPushService delivery', () => {
it('WPUSH-001: posts one encrypted, VAPID-signed message through the SSRF guard', async () => {
const { user } = createUser(testDb);
const device = await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/abc');
safeFetchFollow.mockResolvedValue(reply(201));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
expect(safeFetchFollow).toHaveBeenCalledTimes(1);
const [url, init, options] = safeFetchFollow.mock.calls[0];
expect(url).toBe(device.endpoint);
expect(options).toEqual({ maxRedirects: 0, bypassInternalIpAllowed: true });
expect(init.method).toBe('POST');
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(init.headers).toMatchObject({
TTL: '86400',
Urgency: 'normal',
'Content-Encoding': 'aes128gcm',
'Content-Type': 'application/octet-stream',
});
expect(init.headers.Authorization).toMatch(
new RegExp(`^vapid t=[\\w-]+\\.[\\w-]+\\.[\\w-]+, k=${await keys.getPublicKey()}$`),
);
expect(open(init.body, device)).toEqual({
title: MSG.title,
body: MSG.body,
url: '/trips/12',
tag: 'collab_message:/trips/12',
});
const [row] = await rows(user.id);
expect(row.last_success_at).not.toBeNull();
expect(row.failure_count).toBe(0);
});
it('WPUSH-002: date-bound events go out with high urgency', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://web.push.apple.com/QGx');
safeFetchFollow.mockResolvedValue(reply(201));
await push.sendToUser(user.id, { ...MSG, event: 'trip_reminder' });
expect(safeFetchFollow.mock.calls[0][1].headers.Urgency).toBe('high');
});
it('WPUSH-003: 404 and 410 mean the browser is gone, and the row goes with it', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/expired');
await subscribe(user.id, 'https://updates.push.services.mozilla.com/wpush/v2/revoked');
safeFetchFollow.mockImplementation(async (url: string) => reply(url.includes('expired') ? 404 : 410));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(await rows(user.id)).toEqual([]);
expect(logError).not.toHaveBeenCalled();
});
it('WPUSH-004: a first 403 with Apple’s BadJwtToken keeps the device, counts it and logs the reason', async () => {
// Apple answers 403 for a token it rejects, which is the server's problem
// (subject, clock, key), not a sign the device is gone.
const { user } = createUser(testDb);
await subscribe(user.id, 'https://web.push.apple.com/QGx');
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(await rows(user.id)).toHaveLength(1);
expect((await rows(user.id))[0].failure_count).toBe(1);
const line = logError.mock.calls[0][0] as string;
expect(line).toContain('HTTP 403');
expect(line).toContain('BadJwtToken');
expect(line).toContain('host=web.push.apple.com');
// The endpoint is a capability URL: the log names the host only.
expect(line).not.toContain('/QGx');
});
it(`WPUSH-004b: the device goes after ${FORBIDDEN_FAILURES_BEFORE_REMOVAL} failed sends in a row ending in 403`, async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://web.push.apple.com/QGx');
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
for (let i = 1; i < FORBIDDEN_FAILURES_BEFORE_REMOVAL; i++) {
await push.sendToUser(user.id, MSG);
expect((await rows(user.id))[0].failure_count).toBe(i);
}
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(await rows(user.id)).toEqual([]);
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('removed a device'));
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('BadJwtToken'));
});
it('WPUSH-004c: a success between refusals starts the count over', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://web.push.apple.com/QGx');
safeFetchFollow.mockResolvedValue(reply(403, '{"reason":"BadJwtToken"}'));
for (let i = 1; i < FORBIDDEN_FAILURES_BEFORE_REMOVAL; i++) await push.sendToUser(user.id, MSG);
safeFetchFollow.mockResolvedValueOnce(reply(201));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
expect((await rows(user.id))[0].failure_count).toBe(0);
await push.sendToUser(user.id, MSG);
expect(await rows(user.id)).toHaveLength(1);
expect((await rows(user.id))[0].failure_count).toBe(1);
});
it('WPUSH-005: any other failure keeps the row, counts it and logs the capped body', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/busy');
safeFetchFollow.mockResolvedValue(reply(503, `overloaded ${'x'.repeat(5000)}`));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect((await rows(user.id))[0].failure_count).toBe(1);
const line = logError.mock.calls[0][0] as string;
expect(line).toContain('HTTP 503');
expect(line.length).toBeLessThan(1300);
// A body that cannot be read changes nothing but the log text.
safeFetchFollow.mockResolvedValueOnce({
...reply(500),
text: async () => {
throw new Error('stream reset');
},
});
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect((await rows(user.id))[0].failure_count).toBe(2);
expect(logError).toHaveBeenLastCalledWith(expect.stringMatching(/HTTP 500 .*host=fcm\.googleapis\.com: $/));
});
it('WPUSH-006: a refused or failed request is a failure, never a throw', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/a');
safeFetchFollow.mockRejectedValueOnce(new SsrfBlockedError('Requests to private addresses are not allowed'));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('blocked by SSRF guard'));
safeFetchFollow.mockRejectedValueOnce(new Error('The operation was aborted due to timeout'));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(logError).toHaveBeenLastCalledWith(expect.stringContaining('timeout'));
expect((await rows(user.id))[0].failure_count).toBe(2);
});
it('WPUSH-007: one device getting it is enough', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/ok');
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/down');
safeFetchFollow.mockImplementation(async (url: string) => reply(url.endsWith('/ok') ? 201 : 500));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
// Both hosts are the same origin, so one token served both requests.
const [a, b] = safeFetchFollow.mock.calls.map((c) => c[1].headers.Authorization);
expect(a).toBe(b);
});
it('WPUSH-008: a row made for a previous server key is dropped without a request', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/old');
await updateRows(orm, PushSubscriptions, {}, { vapid_public_key: 'previous-key' });
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(await rows(user.id)).toEqual([]);
});
it('WPUSH-009: a row whose host is not a push service any more is dropped without a request', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/y');
await updateRows(
orm,
PushSubscriptions,
{ endpoint: { $like: '%/x' } },
{ endpoint: 'https://attacker.example.test/collect' },
);
await updateRows(orm, PushSubscriptions, { endpoint: { $like: '%/y' } }, { endpoint: 'not a url' });
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(await rows(user.id)).toEqual([]);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('host=attacker.example.test'));
// Not even a URL: the log still has something to name instead of the value.
expect(logError).toHaveBeenCalledWith(expect.stringContaining('not a known push service user'));
expect(logError).toHaveBeenCalledWith(expect.stringContaining('host=invalid-endpoint'));
});
it('WPUSH-010: broken stored keys count as a failure for that row', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
await updateRows(orm, PushSubscriptions, {}, { auth: '***' });
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect((await rows(user.id))[0].failure_count).toBe(1);
});
it('WPUSH-011: nothing to send to is simply false', async () => {
const { user } = createUser(testDb);
expect(await push.hasDevices(user.id)).toBe(false);
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
});
it('WPUSH-012: a failure before any request (no key pair) is logged and false', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
const spy = vi.spyOn(keys, 'getKeys').mockRejectedValueOnce(new Error('database is locked'));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
spy.mockRestore();
});
it('WPUSH-012b: while the stored key pair cannot be used nothing is sent and every device stays', async () => {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
const publicKey = await keys.getPublicKey();
// A restore under a different ENCRYPTION_KEY: the ciphertext no longer opens.
await updateRows(orm, AppSettings, { key: VAPID_PRIVATE_KEY_SETTING }, { value: 'enc:v1:bm90IGEgY2lwaGVydGV4dA' });
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(await rows(user.id)).toHaveLength(1);
expect((await rows(user.id))[0].vapid_public_key).toBe(publicKey);
expect((await rows(user.id))[0].failure_count).toBe(0);
expect(await push.isAvailable()).toBe(false);
// The test send says why instead of a bare failure, and sends nothing either.
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
expect(safeFetchFollow).not.toHaveBeenCalled();
});
it('WPUSH-012c: an install that only ran on VAPID_* generates nothing when that pair breaks, and keeps every device', async () => {
const pair = generateVapidKeyPair();
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
try {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
await subscribe(user.id, 'https://web.push.apple.com/y');
// An existingSecret without the private half: the variable is simply gone.
vi.stubEnv('VAPID_PRIVATE_KEY', '');
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
expect(await push.isAvailable()).toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
expect(await findRows(orm, AppSettings, { key: { $like: 'web_push_vapid_%' } })).toEqual([]);
expect((await rows(user.id)).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]);
// The Secret fixed: the same devices receive again, nobody turns push on again.
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
safeFetchFollow.mockResolvedValue(reply(201));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
expect(safeFetchFollow).toHaveBeenCalledTimes(2);
expect(await rows(user.id)).toHaveLength(2);
} finally {
vi.unstubAllEnvs();
}
});
it('WPUSH-012d: a broken VAPID_* pair over a pair stored from before sends nothing, keeps every device and serves no key', async () => {
// The first start without VAPID_* stored a pair; the operator then brought their own.
const storedKey = await keys.getPublicKey();
const pair = generateVapidKeyPair();
vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey);
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
const controller = new PushController(keys, subscriptions);
try {
const { user } = createUser(testDb);
await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
await subscribe(user.id, 'https://web.push.apple.com/y');
// An existingSecret rotated without the private half.
vi.stubEnv('VAPID_PRIVATE_KEY', '');
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false);
await expect(push.sendTest(user.id)).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR });
expect(await push.isAvailable()).toBe(false);
expect(safeFetchFollow).not.toHaveBeenCalled();
// Signing with the stored pair would have dropped both rows; they stay on the environment key.
expect((await rows(user.id)).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]);
expect(await readAppSetting(orm, VAPID_PUBLIC_KEY_SETTING)).toBe(storedKey);
// Browsers are not handed the stored key to subscribe again with either.
let refusal: unknown = null;
try {
await controller.publicKey();
} catch (err) {
refusal = err;
}
expect(refusal).toBeInstanceOf(HttpException);
expect((refusal as HttpException).getStatus()).toBe(503);
// The Secret fixed: the same rows receive again, and the key served is the one they hold.
vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey);
safeFetchFollow.mockResolvedValue(reply(201));
await expect(push.sendToUser(user.id, MSG)).resolves.toBe(true);
expect(safeFetchFollow).toHaveBeenCalledTimes(2);
expect(await rows(user.id)).toHaveLength(2);
expect(await controller.publicKey()).toEqual({ publicKey: pair.publicKey });
} finally {
vi.unstubAllEnvs();
}
});
it('WPUSH-013: the test send reports success and failure like the other channels', async () => {
const { user } = createUser(testDb);
const device = await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x');
safeFetchFollow.mockResolvedValueOnce(reply(201));
expect(await push.isAvailable()).toBe(true);
await expect(push.sendTest(user.id)).resolves.toEqual({ success: true });
const sent = open(safeFetchFollow.mock.calls[0][1].body, device);
expect(sent).toMatchObject({ title: 'Test Notification', url: '/settings?tab=notifications' });
// Only chat collapses; a second test is a second notification.
expect(sent).not.toHaveProperty('tag');
safeFetchFollow.mockResolvedValueOnce(reply(500));
await expect(push.sendTest(user.id)).resolves.toEqual({
success: false,
error: 'Failed to send push notification',
});
});
});
describe('buildPushPayload', () => {
const size = (p: unknown) => Buffer.byteLength(JSON.stringify(p), 'utf8');
it('WPUSH-PAY-001: passes a short message through; chat collapses per trip', () => {
expect(buildPushPayload(MSG)).toEqual({
title: MSG.title,
body: MSG.body,
url: '/trips/12',
tag: 'collab_message:/trips/12',
});
// Without a trip to collapse on there is no tag at all, never one for every chat everywhere.
expect(buildPushPayload({ ...MSG, navigateTarget: undefined })).toEqual({
title: MSG.title,
body: MSG.body,
});
});
it('WPUSH-PAY-001b: every other event has no tag, so two of them never replace each other', () => {
const others = ALL_EVENT_TYPES.filter((event) => event !== 'collab_message');
expect(others.length).toBeGreaterThan(5);
for (const event of others) {
const payload = buildPushPayload({ ...MSG, event });
expect(payload, event).toEqual({ title: MSG.title, body: MSG.body, url: '/trips/12' });
expect(payload, event).not.toHaveProperty('tag');
}
});
it('WPUSH-PAY-002: cuts a long body at a character boundary until the payload fits', () => {
const payload = buildPushPayload({ ...MSG, body: '🚄'.repeat(5000) });
expect(size(payload)).toBeLessThanOrEqual(MAX_PUSH_PAYLOAD_BYTES);
expect(size(payload)).toBeGreaterThan(MAX_PUSH_PAYLOAD_BYTES - 8);
expect(payload.body.endsWith('…')).toBe(true);
expect(payload.body.slice(0, -1)).toMatch(/^(🚄)+$/u);
});
it('WPUSH-PAY-003: characters JSON has to escape are counted as what they cost', () => {
const payload = buildPushPayload({ ...MSG, title: '\u0001'.repeat(500), body: '"\u0002'.repeat(3000) });
expect(size(payload)).toBeLessThanOrEqual(MAX_PUSH_PAYLOAD_BYTES);
expect(Array.from(payload.title)).toHaveLength(120);
expect(payload.body.length).toBeGreaterThan(0);
});
it('WPUSH-PAY-004: only same-origin paths reach the worker', () => {
expect(samePagePath('/trips/5')).toBe('/trips/5');
expect(samePagePath('/admin?tab=storage')).toBe('/admin?tab=storage');
for (const target of [
'https://evil.test/x',
'//evil.test/x',
'/\\evil.test',
'trips/5',
'/trips/5 x',
`/${'a'.repeat(300)}`,
'',
undefined,
]) {
expect(samePagePath(target), String(target)).toBeUndefined();
}
expect(buildPushPayload({ ...MSG, navigateTarget: 'https://evil.test/' })).not.toHaveProperty('url');
});
});