import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import express, { type Request, type Response, type NextFunction } from 'express'; import request from 'supertest'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { Repository } from '../db/repository.js'; import { VapidKeyStore } from '../vapid-store.js'; import { PushService } from '../push-service.js'; import { mountNotificationsApi, resetRateLimitsForTest } from './notifications-api.js'; // Mock web-push so the test never makes real network calls. vi.mock('web-push', () => { const sendNotification = vi.fn().mockResolvedValue({ statusCode: 201 }); return { default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: () => ({ publicKey: 'BPubKeyMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMa', privateKey: 'privateKeyMaterialMaterialMaterialMaterial', }), }, }; }); const SUBJECT = 'https://aao.example/'; function buildApp(opts: { repo: Repository; pushService: PushService | null; vapidStore: VapidKeyStore | null; userId: string; }): express.Application { const app = express(); const requireAuth = (req: Request, _res: Response, next: NextFunction) => { (req as unknown as { user: { id: string; role: string } }).user = { id: opts.userId, role: 'user', }; next(); }; mountNotificationsApi(app, { repo: opts.repo, pushService: opts.pushService, vapidStore: opts.vapidStore, requireAuth, }); return app; } describe('/api/notifications/*', () => { let tempDir = ''; let repo: Repository; let store: VapidKeyStore; let service: PushService; let app: express.Application; let userId = ''; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-notif-api-')); repo = new Repository(join(tempDir, 'db.sqlite')); store = new VapidKeyStore(join(tempDir, 'vapid.json'), join(tempDir, 'vapid-history')); store.loadOrGenerate(SUBJECT); service = new PushService(repo, store); const user = repo.createUser({ email: 'u@example.com', name: 'u', role: 'user', status: 'active' }); userId = user.id; app = buildApp({ repo, pushService: service, vapidStore: store, userId }); resetRateLimitsForTest(); }); afterEach(() => { repo.close(); rmSync(tempDir, { recursive: true, force: true }); }); describe('GET /vapid-public-key', () => { it('returns public key + keyId, never private key', async () => { const r = await request(app).get('/api/notifications/vapid-public-key'); expect(r.status).toBe(200); expect(r.body.publicKey).toBeTruthy(); expect(r.body.keyId).toBeTruthy(); expect(r.body).not.toHaveProperty('privateKey'); }); it('503 when push disabled (no service)', async () => { const appOff = buildApp({ repo, pushService: null, vapidStore: null, userId }); const r = await request(appOff).get('/api/notifications/vapid-public-key'); expect(r.status).toBe(503); }); }); describe('subscriptions lifecycle', () => { const validBody = { endpoint: 'https://fcm.googleapis.com/fcm/send/abc', p256dh: 'pubkey', auth: 'authsec', userAgent: 'Chrome on Pixel', }; it('POST → GET → DELETE full cycle', async () => { const post = await request(app).post('/api/notifications/subscriptions').send(validBody); expect(post.status).toBe(200); const id = post.body.id; const list = await request(app).get('/api/notifications/subscriptions'); expect(list.status).toBe(200); expect(list.body.subscriptions).toHaveLength(1); expect(list.body.subscriptions[0]).not.toHaveProperty('p256dh'); expect(list.body.subscriptions[0]).not.toHaveProperty('auth'); expect(list.body.subscriptions[0]).not.toHaveProperty('endpoint'); expect(list.body.subscriptions[0].endpointHost).toBe('fcm.googleapis.com'); const del = await request(app).delete(`/api/notifications/subscriptions/${id}`); expect(del.status).toBe(200); const list2 = await request(app).get('/api/notifications/subscriptions'); expect(list2.body.subscriptions).toHaveLength(0); }); it('rejects non-https endpoint', async () => { const r = await request(app).post('/api/notifications/subscriptions').send({ ...validBody, endpoint: 'http://insecure.example/x', }); expect(r.status).toBe(400); }); it('rejects missing p256dh / auth', async () => { const r1 = await request(app).post('/api/notifications/subscriptions').send({ endpoint: validBody.endpoint, auth: 'a', }); expect(r1.status).toBe(400); const r2 = await request(app).post('/api/notifications/subscriptions').send({ endpoint: validBody.endpoint, p256dh: 'p', }); expect(r2.status).toBe(400); }); it('DELETE: cannot delete another user\'s subscription (returns 404)', async () => { // create another user with a subscription const other = repo.createUser({ email: 'b@example.com', name: 'b', role: 'user', status: 'active' }); const otherSub = repo.upsertPushSubscription({ userId: other.id, endpoint: 'https://fcm.googleapis.com/fcm/send/other', p256dh: 'p', auth: 'a', vapidKeyId: store.getCurrent().keyId, }); const del = await request(app).delete(`/api/notifications/subscriptions/${otherSub.id}`); expect(del.status).toBe(404); // still there in DB expect(repo.getPushSubscriptionById(otherSub.id)).not.toBeNull(); }); it('endpoint UNIQUE: re-POST from a different user transfers ownership', async () => { // Seed userA's existing subscription with the same endpoint const other = repo.createUser({ email: 'a@example.com', name: 'a', role: 'user', status: 'active' }); repo.upsertPushSubscription({ userId: other.id, endpoint: validBody.endpoint, p256dh: 'oldp', auth: 'olda', vapidKeyId: store.getCurrent().keyId, }); // Current user (userId) re-subscribes the same endpoint const r = await request(app).post('/api/notifications/subscriptions').send(validBody); expect(r.status).toBe(200); const meSubs = repo.listPushSubscriptionsForUser(userId); const otherSubs = repo.listPushSubscriptionsForUser(other.id); expect(meSubs).toHaveLength(1); expect(otherSubs).toHaveLength(0); }); it('rate limit: 11th subscribe in same hour returns 429', async () => { for (let i = 0; i < 10; i++) { const r = await request(app).post('/api/notifications/subscriptions').send({ ...validBody, endpoint: `https://fcm.googleapis.com/fcm/send/${i}`, }); expect(r.status).toBe(200); } const r11 = await request(app).post('/api/notifications/subscriptions').send({ ...validBody, endpoint: 'https://fcm.googleapis.com/fcm/send/11', }); expect(r11.status).toBe(429); expect(r11.body.retryAfter).toBeGreaterThan(0); }); }); describe('preferences', () => { it('GET returns defaults for new users', async () => { const r = await request(app).get('/api/notifications/preferences'); expect(r.status).toBe(200); expect(r.body.enabled).toBe(true); expect(r.body.events).toEqual({ running: true, succeeded: true, failed: true, waiting_human: true, }); expect(r.body.includeDetails).toBe(false); expect(r.body.v1Migrated).toBe(false); }); it('PUT applies partial update', async () => { const r = await request(app).put('/api/notifications/preferences').send({ enabled: false, events: { succeeded: false }, }); expect(r.status).toBe(200); expect(r.body.enabled).toBe(false); expect(r.body.events.succeeded).toBe(false); expect(r.body.events.running).toBe(true); }); it('PUT rejects non-boolean fields', async () => { const r = await request(app).put('/api/notifications/preferences').send({ enabled: 'yes', }); expect(r.status).toBe(400); }); it('migrate-from-localstorage: first call applies, second returns 409', async () => { const r1 = await request(app) .post('/api/notifications/preferences/migrate-from-localstorage') .send({ enabled: true, events: { running: false } }); expect(r1.status).toBe(200); expect(r1.body.prefs.v1Migrated).toBe(true); expect(r1.body.prefs.events.running).toBe(false); const r2 = await request(app) .post('/api/notifications/preferences/migrate-from-localstorage') .send({ enabled: false }); expect(r2.status).toBe(409); }); }); describe('test endpoint', () => { it('returns 200 ok when push enabled', async () => { const r = await request(app).post('/api/notifications/test'); expect(r.status).toBe(200); expect(r.body.ok).toBe(true); }); it('503 when push disabled', async () => { const appOff = buildApp({ repo, pushService: null, vapidStore: null, userId }); const r = await request(appOff).post('/api/notifications/test'); expect(r.status).toBe(503); }); it('rate limit: 6th test in same hour returns 429', async () => { for (let i = 0; i < 5; i++) { const r = await request(app).post('/api/notifications/test'); expect(r.status).toBe(200); } const r6 = await request(app).post('/api/notifications/test'); expect(r6.status).toBe(429); }); }); });