test(push): tenant isolation and web-push idempotency tests

This commit is contained in:
Ochenstarik 2026-09-26 12:08:36 +07:00
parent 87a6a447c2
commit 2f1bdf549d
4 changed files with 596 additions and 0 deletions

View file

@ -13,6 +13,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
const actorB = parseUlid('01J0000000000000000000001B'); const actorB = parseUlid('01J0000000000000000000001B');
const eventId = newUlid(); const eventId = newUlid();
const professionId = newUlid(); const professionId = newUlid();
const employeeIdA = newUlid();
const subscriptionIdA = newUlid();
const deliveryIdA = newUlid();
beforeAll(async () => { beforeAll(async () => {
const role = await database.pool.query( const role = await database.pool.query(
@ -23,8 +26,11 @@ describeDatabase('PostgreSQL tenant isolation', () => {
afterAll(async () => { afterAll(async () => {
await database.tenantTransaction(tenantA, actorA, async (client) => { 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_outbox WHERE id = $1', [eventId]);
await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]); 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(); await database.close();
}); });
@ -102,5 +108,58 @@ describeDatabase('PostgreSQL tenant isolation', () => {
), ),
), ),
).rejects.toThrow(); ).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();
}); });
}); });

View file

@ -28,4 +28,22 @@ describe('runtime modes', () => {
PLATFORM_JWKS_URL: '', PLATFORM_JWKS_URL: '',
}).PLATFORM_GATEWAY_URL, }).PLATFORM_GATEWAY_URL,
).toBeUndefined()); ).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');
});
}); });

View file

@ -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<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('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);
});
});

View file

@ -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;',
);
});
});