test(push): tenant isolation and web-push idempotency tests
This commit is contained in:
parent
87a6a447c2
commit
2f1bdf549d
4 changed files with 596 additions and 0 deletions
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
477
tests/unit/employee-push.test.ts
Normal file
477
tests/unit/employee-push.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue