krona-staff/tests/unit/employee-push.test.ts
Ochenstarik 513cdff702 style: establish repository formatting baseline
Run the configured formatter across the repository before audit remediation so later security fixes produce reviewable, conflict-resistant diffs.
2026-09-26 15:36:20 +07:00

486 lines
16 KiB
TypeScript
Raw 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.

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);
});
});