484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
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<typeof createTestRecipient>,
|
||
): 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<Array<Record<string, unknown>>>) {
|
||
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<unknown>,
|
||
) => {
|
||
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);
|
||
});
|
||
});
|