mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
/**
|
||
* Web Push routes e2e: /api/notifications/push/* and the push test send
|
||
* through the real JwtAuthGuard, the Zod pipe and the real providers on a temp
|
||
* SQLite db. Only the outbound POST (safeFetchFollow) is replaced.
|
||
*/
|
||
import { db } from '../../src/db/database';
|
||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||
import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
|
||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||
import { sessionCookie } from './harness';
|
||
import { Test } from '@nestjs/testing';
|
||
|
||
import cookieParser from 'cookie-parser';
|
||
import type { Server } from 'http';
|
||
import { createECDH } from 'node:crypto';
|
||
import request from 'supertest';
|
||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||
|
||
// The migrated snapshot (users for the guard, app_settings and the
|
||
// push_subscriptions table from legacy step 245), opened inside the factory;
|
||
// the test reaches it through the mocked module.
|
||
vi.mock('../../src/db/database', async () => {
|
||
const { createSnapshotTestDb } = await import('../helpers/db-mock');
|
||
const db = createSnapshotTestDb();
|
||
return { db, closeDb: () => {}, reinitialize: () => {} };
|
||
});
|
||
|
||
const { safeFetchFollow } = vi.hoisted(() => ({ safeFetchFollow: vi.fn() }));
|
||
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
|
||
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
|
||
safeFetchFollow,
|
||
}));
|
||
|
||
const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/e2e-device';
|
||
|
||
function subscription(endpoint = ENDPOINT) {
|
||
const ua = createECDH('prime256v1');
|
||
ua.generateKeys();
|
||
return {
|
||
endpoint,
|
||
expirationTime: null,
|
||
keys: { p256dh: ua.getPublicKey('base64url'), auth: Buffer.alloc(16, 3).toString('base64url') },
|
||
};
|
||
}
|
||
|
||
function rows() {
|
||
return db
|
||
.prepare('SELECT user_id, endpoint, vapid_public_key, user_agent FROM push_subscriptions ORDER BY id')
|
||
.all() as {
|
||
user_id: number;
|
||
endpoint: string;
|
||
vapid_public_key: string;
|
||
user_agent: string | null;
|
||
}[];
|
||
}
|
||
|
||
describe('Web Push e2e (real auth guard + temp SQLite)', () => {
|
||
let server: Server;
|
||
let app: Awaited<ReturnType<typeof build>>;
|
||
|
||
async function build() {
|
||
const moduleRef = await Test.createTestingModule({
|
||
imports: [
|
||
await TestUnitOfWorkModule.forRoot(db),
|
||
await createTestMikroOrmModule(db),
|
||
RealtimeModule,
|
||
NotificationsModule,
|
||
],
|
||
}).compile();
|
||
const nest = moduleRef.createNestApplication();
|
||
nest.use(cookieParser());
|
||
nest.useGlobalPipes(new ZodValidationPipe());
|
||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||
await nest.init();
|
||
return nest;
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
// harness.ts's seedUser() omits password_hash, which the migrated schema
|
||
// requires NOT NULL: a raw insert of the same SeededUser shape instead.
|
||
const seed = db.prepare(
|
||
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (?, ?, ?, 'x', 'user', 0)",
|
||
);
|
||
seed.run(1, 'e2e-user-1', 'one@example.test');
|
||
seed.run(2, 'e2e-user-2', 'two@example.test');
|
||
app = await build();
|
||
server = app.getHttpServer();
|
||
});
|
||
|
||
beforeEach(() => {
|
||
db.prepare('DELETE FROM push_subscriptions').run();
|
||
safeFetchFollow.mockReset();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app.close();
|
||
});
|
||
|
||
it('401 on every push route without a session cookie', async () => {
|
||
expect((await request(server).get('/api/notifications/push/public-key')).status).toBe(401);
|
||
expect(
|
||
(await request(server).post('/api/notifications/push/subscriptions').send({ subscription: subscription() }))
|
||
.status,
|
||
).toBe(401);
|
||
expect(
|
||
(await request(server).delete('/api/notifications/push/subscriptions').send({ endpoint: ENDPOINT })).status,
|
||
).toBe(401);
|
||
});
|
||
|
||
it('GET public-key: the pair exists from bootstrap on, so the route only reads it', async () => {
|
||
const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'web_push_vapid_public_key'").get() as {
|
||
value: string;
|
||
};
|
||
const res = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual({ publicKey: stored.value });
|
||
const raw = Buffer.from(res.body.publicKey, 'base64url');
|
||
expect(raw.length).toBe(65);
|
||
expect(raw[0]).toBe(0x04);
|
||
});
|
||
|
||
it('POST subscriptions: 200 with the device count, stored against the served key', async () => {
|
||
const { body: key } = await request(server)
|
||
.get('/api/notifications/push/public-key')
|
||
.set('Cookie', sessionCookie(1));
|
||
const res = await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.set('User-Agent', 'e2e-browser/1.0')
|
||
.send({ subscription: subscription() });
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual({ success: true, devices: 1 });
|
||
expect(rows()).toEqual([
|
||
{ user_id: 1, endpoint: ENDPOINT, vapid_public_key: key.publicKey, user_agent: 'e2e-browser/1.0' },
|
||
]);
|
||
});
|
||
|
||
it('POST subscriptions: the same browser subscribing for another account moves to it', async () => {
|
||
await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ subscription: subscription() });
|
||
const res = await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(2))
|
||
.send({ subscription: subscription() });
|
||
expect(res.body).toEqual({ success: true, devices: 1 });
|
||
expect(rows().map((r) => r.user_id)).toEqual([2]);
|
||
});
|
||
|
||
it('POST subscriptions: 400 with the bespoke message for a host that is not a push service', async () => {
|
||
const res = await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ subscription: subscription('https://example.com/collect') });
|
||
expect(res.status).toBe(400);
|
||
expect(res.body).toEqual({ error: 'Push endpoint is not a known push service' });
|
||
expect(rows()).toEqual([]);
|
||
});
|
||
|
||
it('POST subscriptions: 400 from the contract when the keys are missing', async () => {
|
||
const res = await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ subscription: { endpoint: ENDPOINT } });
|
||
expect(res.status).toBe(400);
|
||
expect(res.body.error).toEqual(expect.stringContaining('keys'));
|
||
});
|
||
|
||
it('DELETE subscriptions: only the owner removes the endpoint, and a repeat is still 200', async () => {
|
||
await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ subscription: subscription() });
|
||
|
||
const stranger = await request(server)
|
||
.delete('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(2))
|
||
.send({ endpoint: ENDPOINT });
|
||
expect(stranger.status).toBe(200);
|
||
expect(rows()).toHaveLength(1);
|
||
|
||
const owner = await request(server)
|
||
.delete('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ endpoint: ENDPOINT });
|
||
expect(owner.status).toBe(200);
|
||
expect(owner.body).toEqual({ success: true });
|
||
expect(rows()).toEqual([]);
|
||
|
||
const again = await request(server)
|
||
.delete('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ endpoint: ENDPOINT });
|
||
expect(again.status).toBe(200);
|
||
});
|
||
|
||
it('DELETE subscriptions: 400 without an endpoint', async () => {
|
||
const res = await request(server)
|
||
.delete('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('POST test/push: sends the test message to the caller’s devices through the SSRF-guarded fetch', async () => {
|
||
const none = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
|
||
expect(none.status).toBe(200);
|
||
expect(none.body).toEqual({ success: false, error: 'Channel is not configured for this user' });
|
||
|
||
await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ subscription: subscription() });
|
||
safeFetchFollow.mockResolvedValue({ ok: true, status: 201, body: null, headers: { get: () => null } });
|
||
const res = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual({ success: true });
|
||
expect(safeFetchFollow).toHaveBeenCalledWith(
|
||
ENDPOINT,
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
headers: expect.objectContaining({ 'Content-Encoding': 'aes128gcm' }),
|
||
}),
|
||
{ maxRedirects: 0, bypassInternalIpAllowed: true },
|
||
);
|
||
});
|
||
|
||
it('a stored private key that no longer decrypts turns push off with a 503 and is never replaced', async () => {
|
||
const readKey = (key: string) =>
|
||
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string }).value;
|
||
const unreadable = 'enc:v1:bm90IGEgY2lwaGVydGV4dA';
|
||
const publicKey = readKey('web_push_vapid_public_key');
|
||
const ciphertext = readKey('web_push_vapid_private_key');
|
||
await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ subscription: subscription() });
|
||
db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(unreadable, 'web_push_vapid_private_key');
|
||
try {
|
||
const key = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
|
||
expect(key.status).toBe(503);
|
||
expect(key.body).toEqual({ error: 'Web Push is currently unavailable on this server' });
|
||
const sub = await request(server)
|
||
.post('/api/notifications/push/subscriptions')
|
||
.set('Cookie', sessionCookie(1))
|
||
.send({ subscription: subscription(`${ENDPOINT}-second`) });
|
||
expect(sub.status).toBe(503);
|
||
const test = await request(server).post('/api/notifications/test/push').set('Cookie', sessionCookie(1));
|
||
expect(test.status).toBe(200);
|
||
expect(test.body).toEqual({ success: false, error: 'Web Push is currently unavailable on this server' });
|
||
expect(safeFetchFollow).not.toHaveBeenCalled();
|
||
// The device registered before stays, and neither stored row was touched.
|
||
expect(rows().map((r) => r.endpoint)).toEqual([ENDPOINT]);
|
||
expect(readKey('web_push_vapid_public_key')).toBe(publicKey);
|
||
expect(readKey('web_push_vapid_private_key')).toBe(unreadable);
|
||
} finally {
|
||
db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, 'web_push_vapid_private_key');
|
||
}
|
||
const back = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1));
|
||
expect(back.body).toEqual({ publicKey });
|
||
});
|
||
});
|