import crypto from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; import { newUlid } from '../../src/core/ids.js'; import { createVapidAuthorizationHeader, encryptPushPayload, generateVapidKeys, resolveVapidKeys, sendWebPush, } from '../../src/domain/notification/web-push.js'; import { PushDispatcher } from '../../src/domain/notification/push-dispatcher.js'; function createTestRecipient() { const ecdh = crypto.createECDH('prime256v1'); ecdh.generateKeys(); const auth = crypto.randomBytes(16); return { ecdh, publicKey: ecdh.getPublicKey(), // 65 bytes auth, keys: { p256dh: ecdh.getPublicKey().toString('base64url'), auth: auth.toString('base64url'), }, }; } function decryptPushPayload( encrypted: Buffer, recipient: ReturnType, ): string { const salt = encrypted.subarray(0, 16); const idlen = encrypted[20]!; const senderPublicKey = encrypted.subarray(21, 21 + idlen); const ciphertextAndTag = encrypted.subarray(21 + idlen); const ciphertext = ciphertextAndTag.subarray(0, ciphertextAndTag.length - 16); const tag = ciphertextAndTag.subarray(ciphertextAndTag.length - 16); const sharedSecret = recipient.ecdh.computeSecret(senderPublicKey); const keyInfo = Buffer.concat([ Buffer.from('WebPush: info\0', 'utf8'), recipient.publicKey, senderPublicKey, ]); const prk = Buffer.from( crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32), ); const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8'); const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16)); const nonceInfo = Buffer.from('Content-Encoding: nonce\0', 'utf8'); const nonce = Buffer.from(crypto.hkdfSync('sha256', prk, salt, nonceInfo, 12)); const decipher = crypto.createDecipheriv('aes-128-gcm', cek, nonce); decipher.setAuthTag(tag); const decryptedRecord = Buffer.concat([decipher.update(ciphertext), decipher.final()]); // Strip trailing delimiter byte (0x02) expect(decryptedRecord[decryptedRecord.length - 1]).toBe(0x02); return decryptedRecord.subarray(0, decryptedRecord.length - 1).toString('utf8'); } describe('WebPush VAPID key management', () => { it('generates valid uncompressed P-256 public keys and scalar private keys', () => { const keys = generateVapidKeys('mailto:test@krona.local'); const pubBuf = Buffer.from(keys.publicKey, 'base64url'); expect(pubBuf).toHaveLength(65); expect(pubBuf[0]).toBe(0x04); expect(keys.subject).toBe('mailto:test@krona.local'); expect(keys.privateKey.length).toBeGreaterThanOrEqual(40); }); it('provides safe ephemeral fallback keys in development/test when env keys are absent', () => { const keys1 = resolveVapidKeys({}); const keys2 = resolveVapidKeys({}); expect(keys1.publicKey).toBe(keys2.publicKey); expect(keys1.privateKey).toBe(keys2.privateKey); }); it('rejects mismatched VAPID configuration when only one key is set', () => { expect(() => resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' }), ).toThrow(/must be configured together/); expect(() => resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' }), ).toThrow(/must be configured together/); }); }); describe('WebPush RFC 8292 VAPID Authorization', () => { it('signs an ES256 JWT with audience matching the push service origin', () => { const vapidKeys = generateVapidKeys('mailto:admin@krona.local'); const endpoint = 'https://fcm.googleapis.com/fcm/send/sub-123'; const authHeader = createVapidAuthorizationHeader(endpoint, vapidKeys); expect(authHeader).toMatch(/^vapid t=[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+, k=/); const match = authHeader.match(/^vapid t=([^,]+), k=(.+)$/); expect(match).not.toBeNull(); const jwt = match?.[1]; const pubKey = match?.[2]; expect(pubKey).toBe(vapidKeys.publicKey); if (!jwt) { throw new Error('jwt missing'); } const parts = jwt.split('.'); expect(parts).toHaveLength(3); const [headerB64, payloadB64, sigB64] = parts; if (!headerB64 || !payloadB64 || !sigB64) { throw new Error('jwt parts missing'); } const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8')) as { typ: string; alg: string; }; const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')) as { aud: string; sub: string; exp: number; }; expect(header).toEqual({ typ: 'JWT', alg: 'ES256' }); expect(payload.aud).toBe('https://fcm.googleapis.com'); expect(payload.sub).toBe('mailto:admin@krona.local'); expect(payload.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)); // Verify ECDSA P-256 signature const signingInput = `${headerB64}.${payloadB64}`; const pubBuf = Buffer.from(vapidKeys.publicKey, 'base64url'); const x = pubBuf.subarray(1, 33).toString('base64url'); const y = pubBuf.subarray(33, 65).toString('base64url'); const publicKeyObject = crypto.createPublicKey({ key: { kty: 'EC', crv: 'P-256', x, y }, format: 'jwk', }); const isValid = crypto.verify( 'SHA256', Buffer.from(signingInput, 'utf8'), { key: publicKeyObject, dsaEncoding: 'ieee-p1363' }, Buffer.from(sigB64, 'base64url'), ); expect(isValid).toBe(true); }); }); describe('WebPush RFC 8291 / RFC 8188 AES-128-GCM encryption', () => { it('encrypts and successfully decrypts payload with recipient keys', () => { const recipient = createTestRecipient(); const originalText = JSON.stringify({ title: 'Krona Staff', body: 'Отметка рабочего времени зарегистрирована', }); const encrypted = encryptPushPayload(originalText, recipient.keys); expect(encrypted.length).toBeGreaterThan(originalText.length); // Verify RFC 8188 header structure const salt = encrypted.subarray(0, 16); expect(salt).toHaveLength(16); const rs = encrypted.readUInt32BE(16); expect(rs).toBe(4096); const idlen = encrypted[20]; expect(idlen).toBe(65); // Decrypt and verify roundtrip const decrypted = decryptPushPayload(encrypted, recipient); expect(decrypted).toBe(originalText); }); it('rejects invalid recipient keys with clear error', () => { expect(() => encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' }), ).toThrow(/Invalid recipient/); }); }); describe('Push notification privacy (Requirement 2)', () => { it('guarantees push notification body contains only neutral text without PII', () => { const payload = { title: 'Krona Staff', body: 'Отметка рабочего времени зарегистрирована', icon: '/staff/icon-192.svg', badge: '/staff/icon-192.svg', tag: 'attendance', data: { url: '/staff', event_type: 'attendance.clock.in' }, }; const serialized = JSON.stringify(payload); // Neutral text check expect(payload.body).toBe('Отметка рабочего времени зарегистрирована'); // Ensure no name, salary, personal identifiers, or money values expect(serialized).not.toMatch(/Иван|Петр|Сидор|зарплат|руб|₽|amount|salary|minor/i); }); }); describe('sendWebPush delivery handling', () => { it('enforces HTTPS on subscription endpoints', async () => { const vapidKeys = generateVapidKeys(); const recipient = createTestRecipient(); await expect( sendWebPush( { endpoint: 'http://insecure.example.com/push', keys: recipient.keys }, 'test', vapidKeys, ), ).rejects.toThrow(/Subscription endpoint must use HTTPS/); }); it('reports successful delivery on 201 Created from push service', async () => { const vapidKeys = generateVapidKeys(); const recipient = createTestRecipient(); const mockFetch = vi.fn(async () => new Response(null, { status: 201 })); const result = await sendWebPush( { endpoint: 'https://push.example.com/sub/1', keys: recipient.keys }, 'test payload', vapidKeys, { fetchFn: mockFetch as unknown as typeof fetch }, ); expect(result.successful).toBe(true); expect(result.statusCode).toBe(201); expect(result.shouldRemoveSubscription).toBe(false); }); it('flags 404 and 410 for expired subscription removal', async () => { const vapidKeys = generateVapidKeys(); const recipient = createTestRecipient(); const mockFetch410 = vi.fn(async () => new Response('Gone', { status: 410 })); const result = await sendWebPush( { endpoint: 'https://push.example.com/sub/expired', keys: recipient.keys }, 'test payload', vapidKeys, { fetchFn: mockFetch410 as unknown as typeof fetch }, ); expect(result.successful).toBe(false); expect(result.statusCode).toBe(410); expect(result.shouldRemoveSubscription).toBe(true); }); }); describe('PushDispatcher idempotent outbox handling', () => { const tenantId = '01J00000000000000000000000'; const systemActorId = '01J00000000000000000000001'; const employeeId = '01J00000000000000000000002'; const outboxId = '01J00000000000000000000003'; const vapidKeys = generateVapidKeys(); function mockDb(responses: Array>>) { const queries: Array<{ sql: string; params: readonly unknown[] | undefined }> = []; const database = { tenantTransaction: vi.fn( async ( _tenant: unknown, _actor: unknown, callback: (client: { query: typeof queryFn }) => Promise, ) => { return callback({ query: queryFn }); }, ), }; async function queryFn(sql: string, params?: readonly unknown[]) { queries.push({ sql, params }); const rows = responses.shift() ?? []; return { rows, rowCount: rows.length }; } return { database: database as never, queries }; } it('rejects saving non-HTTPS subscription endpoints', async () => { const { database } = mockDb([]); const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId }); await expect( dispatcher.saveSubscription(tenantId, employeeId, { endpoint: 'http://plain-http.example.com/sub', p256dh: 'p256dh', auth: 'auth', }), ).rejects.toThrow(/Subscription endpoint must use HTTPS/); }); it('leaves the platform outbox untouched when employee has no subscriptions', async () => { const { database, queries } = mockDb([ // 1. SELECT staff_outbox [ { id: outboxId, tenant_id: tenantId, event_type: 'attendance.clock.in', subject_id: employeeId, payload: {}, published_at: null, attempts: 0, }, ], // 2. SELECT staff_push_subscriptions (empty) [], ]); const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId }); const report = await dispatcher.dispatchForOutbox(tenantId, outboxId); expect(report.published).toBe(true); expect(report.subscriptionsFound).toBe(0); expect(queries).toHaveLength(2); expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false); }); it('deletes only a subscription owned by the current employee', async () => { const endpoint = 'https://push.example.com/sub/current-device'; const { database, queries } = mockDb([[{ id: newUlid() }]]); const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId }); await dispatcher.deleteSubscription(tenantId, employeeId, endpoint); expect(queries[0]?.sql).toContain('employee_id = $2'); expect(queries[0]?.params).toEqual([tenantId, employeeId, endpoint]); }); it('delivers push idempotently and avoids duplicate delivery', async () => { const recipient = createTestRecipient(); const endpoint = 'https://push.example.com/sub/active'; const mockFetch = vi.fn(async () => new Response(null, { status: 201 })); const { database, queries } = mockDb([ // 1. SELECT staff_outbox [ { id: outboxId, tenant_id: tenantId, event_type: 'attendance.clock.in', subject_id: employeeId, payload: {}, published_at: null, attempts: 0, }, ], // 2. SELECT staff_push_subscriptions [ { id: newUlid(), endpoint, p256dh: recipient.keys.p256dh, auth: recipient.keys.auth, user_agent: 'TestAgent', }, ], // 3. INSERT staff_notification_deliveries ON CONFLICT DO NOTHING [], // 4. SELECT current delivery record [{ id: newUlid(), status: 'pending', attempts: 0 }], // 5. UPDATE delivery to delivered [], ]); const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId, fetchFn: mockFetch, }); const report = await dispatcher.dispatchForOutbox(tenantId, outboxId); expect(report.published).toBe(true); expect(report.subscriptionsFound).toBe(1); expect(report.deliveries[0]?.status).toBe('delivered'); expect(mockFetch).toHaveBeenCalledTimes(1); // Push delivery is tracked independently from platform event publication. expect(queries.some((q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'))).toBe(true); expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false); }); it('skips sending if endpoint was already delivered (idempotency check)', async () => { const recipient = createTestRecipient(); const endpoint = 'https://push.example.com/sub/active'; const mockFetch = vi.fn(async () => new Response(null, { status: 201 })); const { database } = mockDb([ // 1. SELECT staff_outbox [ { id: outboxId, tenant_id: tenantId, event_type: 'attendance.clock.in', subject_id: employeeId, payload: {}, published_at: null, attempts: 0, }, ], // 2. SELECT staff_push_subscriptions [ { id: newUlid(), endpoint, p256dh: recipient.keys.p256dh, auth: recipient.keys.auth, user_agent: 'TestAgent', }, ], // 3. INSERT delivery ON CONFLICT DO NOTHING [], // 4. SELECT current delivery: already 'delivered' [{ id: newUlid(), status: 'delivered', attempts: 1 }], // 5. UPDATE outbox to published [], ]); const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId, fetchFn: mockFetch, }); const report = await dispatcher.dispatchForOutbox(tenantId, outboxId); expect(report.deliveries[0]?.status).toBe('skipped'); expect(mockFetch).not.toHaveBeenCalled(); expect(report.published).toBe(true); }); it('deletes expired subscription from staff_push_subscriptions on 410 Gone', async () => { const recipient = createTestRecipient(); const endpoint = 'https://push.example.com/sub/expired'; const mockFetch = vi.fn(async () => new Response('Expired', { status: 410 })); const { database, queries } = mockDb([ // 1. SELECT staff_outbox [ { id: outboxId, tenant_id: tenantId, event_type: 'attendance.clock.out', subject_id: employeeId, payload: {}, published_at: null, attempts: 0, }, ], // 2. SELECT staff_push_subscriptions [ { id: newUlid(), endpoint, p256dh: recipient.keys.p256dh, auth: recipient.keys.auth, user_agent: 'TestAgent', }, ], // 3. INSERT delivery [], // 4. SELECT delivery [{ id: newUlid(), status: 'pending', attempts: 0 }], // 5. UPDATE delivery to failed AND DELETE subscription [], // 6. UPDATE outbox [], ]); const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId, fetchFn: mockFetch, }); const report = await dispatcher.dispatchForOutbox(tenantId, outboxId); expect(report.deliveries[0]?.status).toBe('failed'); expect(queries.some((q) => q.sql.includes('DELETE FROM staff_push_subscriptions'))).toBe(true); }); });