maestro/src/bridge/notifications-api.test.ts
2026-06-03 05:08:00 +00:00

251 lines
9.4 KiB
TypeScript

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