251 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|