diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts index 45e1d07..c6ef04a 100644 --- a/tests/integration/tenant-isolation.test.ts +++ b/tests/integration/tenant-isolation.test.ts @@ -13,6 +13,9 @@ describeDatabase('PostgreSQL tenant isolation', () => { const actorB = parseUlid('01J0000000000000000000001B'); const eventId = newUlid(); const professionId = newUlid(); + const employeeIdA = newUlid(); + const subscriptionIdA = newUlid(); + const deliveryIdA = newUlid(); beforeAll(async () => { const role = await database.pool.query( @@ -23,8 +26,11 @@ describeDatabase('PostgreSQL tenant isolation', () => { afterAll(async () => { await database.tenantTransaction(tenantA, actorA, async (client) => { + await client.query('DELETE FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]); + await client.query('DELETE FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]); await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]); await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]); + await client.query('DELETE FROM staff_employees WHERE id = $1', [employeeIdA]); }); await database.close(); }); @@ -102,5 +108,58 @@ describeDatabase('PostgreSQL tenant isolation', () => { ), ), ).rejects.toThrow(); + + // Push subscriptions tenant isolation + await database.tenantTransaction(tenantA, actorA, async (client) => { + await client.query( + `INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name) + VALUES ($1, $2, 'EMP-PUSH-1', 'Тестовый сотрудник')`, + [employeeIdA, tenantA], + ); + await client.query( + `INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth) + VALUES ($1, $2, $3, 'https://push.example.com/subA', 'test-p256dh', 'test-auth')`, + [subscriptionIdA, tenantA, employeeIdA], + ); + }); + + const subVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) => + client.query('SELECT id FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]), + ); + expect(subVisibleToB.rowCount).toBe(0); + + await expect( + database.tenantTransaction(tenantB, actorB, (client) => + client.query( + `INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth) + VALUES ($1, $2, $3, 'https://push.example.com/subB', 'key', 'auth')`, + [newUlid(), tenantA, employeeIdA], + ), + ), + ).rejects.toThrow(); + + // Notification deliveries tenant isolation + await database.tenantTransaction(tenantA, actorA, async (client) => { + await client.query( + `INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status) + VALUES ($1, $2, $3, $4, 'https://push.example.com/subA', 'delivered')`, + [deliveryIdA, tenantA, employeeIdA, eventId], + ); + }); + + const delVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) => + client.query('SELECT id FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]), + ); + expect(delVisibleToB.rowCount).toBe(0); + + await expect( + database.tenantTransaction(tenantB, actorB, (client) => + client.query( + `INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status) + VALUES ($1, $2, $3, $4, 'https://push.example.com/subB', 'pending')`, + [newUlid(), tenantA, employeeIdA, eventId], + ), + ), + ).rejects.toThrow(); }); }); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 8e1f501..94ff9df 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -28,4 +28,22 @@ describe('runtime modes', () => { PLATFORM_JWKS_URL: '', }).PLATFORM_GATEWAY_URL, ).toBeUndefined()); + it('requires VAPID keys to be configured together if provided', () => { + expect(() => + loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' }), + ).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/); + expect(() => + loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' }), + ).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/); + const config = loadConfig({ + ...base, + VAPID_PUBLIC_KEY: 'pubkey', + VAPID_PRIVATE_KEY: 'privkey', + VAPID_SUBJECT: 'mailto:ops@krona.local', + }); + expect(config.VAPID_PUBLIC_KEY).toBe('pubkey'); + expect(config.VAPID_PRIVATE_KEY).toBe('privkey'); + expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local'); + }); }); + diff --git a/tests/unit/employee-push.test.ts b/tests/unit/employee-push.test.ts new file mode 100644 index 0000000..9925f35 --- /dev/null +++ b/tests/unit/employee-push.test.ts @@ -0,0 +1,477 @@ +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('marks outbox published 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) + [], + // 3. UPDATE staff_outbox SET published_at = now() + [], + ]); + + 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[2]?.sql).toContain('UPDATE staff_outbox'); + expect(queries[2]?.sql).toContain('published_at = now()'); + }); + + 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 + [], + // 6. UPDATE outbox to published + [], + ]); + + 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); + + // Verify outbox and delivery updates + expect(queries.some((q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'))).toBe(true); + expect(queries.some((q) => q.sql.includes('staff_outbox') && q.sql.includes('published_at = now()'))).toBe(true); + }); + + 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); + }); +}); diff --git a/tests/unit/tenant-sql.test.ts b/tests/unit/tenant-sql.test.ts index 5468429..598797b 100644 --- a/tests/unit/tenant-sql.test.ts +++ b/tests/unit/tenant-sql.test.ts @@ -139,3 +139,45 @@ describe('employee access migration', () => { }); }); +describe('employee push notifications migration', () => { + const sql = readFileSync('migrations/0010_employee_push_notifications.up.sql', 'utf8'); + const tables = ['staff_push_subscriptions', 'staff_notification_deliveries']; + + it.each(tables)('%s is tenant scoped and forces RLS', (table) => { + const create = sql.slice( + sql.indexOf(`CREATE TABLE ${table}`), + sql.indexOf(';', sql.indexOf(`CREATE TABLE ${table}`)), + ); + expect(create).toContain('tenant_id VARCHAR(26) NOT NULL'); + expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`); + expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`); + expect(sql).toContain(`CREATE POLICY ${table}_tenant_isolation`); + expect(sql).toContain(`tenant_id = current_setting('app.tenant_id', true)`); + }); + + it('declares uniqueness and idempotency constraints with tenant scope', () => { + expect(sql).toContain('UNIQUE (tenant_id, id)'); + expect(sql).toContain('UNIQUE (tenant_id, endpoint)'); + expect(sql).toContain('UNIQUE (tenant_id, outbox_id, endpoint)'); + }); + + it('uses tenant-scoped foreign keys to staff_employees and staff_outbox', () => { + expect(sql).toContain( + 'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE', + ); + expect(sql).toContain( + 'FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE', + ); + }); + + it('grants required operations on both push tables to krona_staff_app', () => { + expect(sql).toContain( + 'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_push_subscriptions TO krona_staff_app;', + ); + expect(sql).toContain( + 'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_notification_deliveries TO krona_staff_app;', + ); + }); +}); + +