mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
368 lines
19 KiB
TypeScript
368 lines
19 KiB
TypeScript
/**
|
|
* Calendar-feed e2e — exercises the subscribable ICS feeds end-to-end against a
|
|
* temp in-memory SQLite db through the REAL JwtAuthGuard:
|
|
* - JWT-guarded token endpoints (/api/trips/:id/feed/token, /api/feed/user/token):
|
|
* lazy generate, idempotency, rotate-invalidates-old, disable-clears-token,
|
|
* host fallback when APP_URL is unset, 404 on no access, 401 no cookie
|
|
* - public unguarded feeds (/api/feed/trip/:token.ics, /api/feed/user/:token.ics):
|
|
* valid token → 200 text/calendar with the injected REFRESH-INTERVAL / X-PUBLISHED-TTL
|
|
* hints, unknown token → 404, all-trips feed excludes archived + >90-day-old trips
|
|
*
|
|
* buildTripCalendar is mocked so the test owns the calendar parts and can assert
|
|
* which trips the all-trips feed pulled in without seeding the full
|
|
* trip/day/reservation schema.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import { DatabaseModule } from '../../src/nest/database/database.module';
|
|
import { Test } from '@nestjs/testing';
|
|
import { APP_GUARD } from '@nestjs/core';
|
|
import { seedUser, sessionCookie } from './harness';
|
|
|
|
const { db } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const Database = require('better-sqlite3');
|
|
const tmp = new Database(':memory:');
|
|
tmp.exec('PRAGMA journal_mode = WAL');
|
|
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user',
|
|
password_version INTEGER NOT NULL DEFAULT 0, feed_token TEXT);`);
|
|
tmp.exec(`CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
|
title TEXT, is_archived INTEGER NOT NULL DEFAULT 0, start_date TEXT, end_date TEXT, feed_token TEXT, currency TEXT);`);
|
|
tmp.exec(`CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);`);
|
|
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
|
// this at onModuleInit.
|
|
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
|
return { db: tmp };
|
|
});
|
|
|
|
// canAccessTrip/isOwner back TripAccessGuard, which now gates the trip token
|
|
// routes. They run the same predicates as the real module against the temp db.
|
|
vi.mock('../../src/db/database', () => ({
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
canAccessTrip: (tripId: number, userId: number) =>
|
|
db
|
|
.prepare(
|
|
`SELECT t.id, t.user_id FROM trips t
|
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
|
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
|
)
|
|
.get(userId, tripId, userId),
|
|
isOwner: (tripId: number, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
}));
|
|
|
|
// Own the calendar parts so we control the events and can assert which trips were pulled.
|
|
const SAMPLE_EVENT =
|
|
'BEGIN:VEVENT\r\nUID:trek-trip-x@trek\r\nDTSTAMP:20260101T000000Z\r\n' +
|
|
'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n';
|
|
const sampleCalendar = () => ({
|
|
calName: 'Sample',
|
|
filename: 'sample.ics',
|
|
timezones: new Map<string, string>(),
|
|
events: [SAMPLE_EVENT],
|
|
});
|
|
// FeedsService injects CalendarService — the mock is a spy on the container
|
|
// singleton (created in beforeAll, after build()).
|
|
const buildTripCalendar = vi.fn();
|
|
|
|
import { FeedsModule } from '../../src/nest/feeds/feeds.module';
|
|
import { CalendarService } from '../../src/nest/calendar/calendar.service';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
|
|
import { GlobalAuthGuard } from '../../src/nest/auth/global-auth.guard';
|
|
import { MfaPolicyGuard } from '../../src/nest/auth/mfa-policy.guard';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
|
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
|
import { Users } from '../../src/db/entities/Users.entity';
|
|
import { WebauthnCredentials } from '../../src/db/entities/WebauthnCredentials.entity';
|
|
|
|
const BASE = 'https://trek.example.test';
|
|
|
|
describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let prevAppUrl: string | undefined;
|
|
|
|
async function build() {
|
|
// The global guards decide whether a route answers a stranger, and they live
|
|
// in AppModule — a suite that pins 'anonymous feed → 200' has to boot them,
|
|
// or it proves nothing about the app that actually runs (a missing @Public()
|
|
// on the feed controller passed here while production 401'd every calendar
|
|
// client).
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [
|
|
await TestUnitOfWorkModule.forRoot(db),
|
|
await createTestMikroOrmModule(db),
|
|
// MfaPolicyGuard (Plan 3b Task 1) needs these three repositories —
|
|
// mirrors app.module.ts's own forFeature, added there for the same
|
|
// reason (MfaPolicyGuard is provided only as an APP_GUARD, never
|
|
// per-controller, so this is the one place that needs it).
|
|
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials]),
|
|
AppConfigModule,
|
|
DatabaseModule,
|
|
RealtimeModule,
|
|
FeedsModule,
|
|
],
|
|
providers: [
|
|
{ provide: APP_GUARD, useClass: GlobalAuthGuard },
|
|
{ provide: APP_GUARD, useClass: MfaPolicyGuard },
|
|
],
|
|
}).compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
prevAppUrl = process.env.APP_URL;
|
|
process.env.APP_URL = BASE;
|
|
seedUser(db as never, { id: 1, username: 'e2e-user' });
|
|
seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' });
|
|
app = await build();
|
|
vi.spyOn(app.get(CalendarService), 'buildTripCalendar').mockImplementation(buildTripCalendar as never);
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
buildTripCalendar.mockReset();
|
|
buildTripCalendar.mockImplementation(() => sampleCalendar());
|
|
// Reset feed tokens + trips between tests for isolation.
|
|
db.exec('DELETE FROM trips; DELETE FROM trip_members; UPDATE users SET feed_token = NULL;');
|
|
db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (5, 1, 'Owned', 0, '2026-01-01', '2099-01-01')").run();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (prevAppUrl === undefined) delete process.env.APP_URL;
|
|
else process.env.APP_URL = prevAppUrl;
|
|
await app.close();
|
|
});
|
|
|
|
// ── Trip token endpoints ───────────────────────────────────────────────────
|
|
|
|
it('401 without a session cookie', async () => {
|
|
expect((await request(server).get('/api/trips/5/feed/token')).status).toBe(401);
|
|
});
|
|
|
|
it('GET token returns {feed_url:null} before one is generated', async () => {
|
|
const res = await request(server).get('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ feed_url: null });
|
|
});
|
|
|
|
it('POST generates a token lazily and is idempotent', async () => {
|
|
const first = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
expect(first.status).toBe(201);
|
|
expect(first.body.feed_url).toMatch(new RegExp(`^${BASE}/api/feed/trip/[0-9a-f-]+\\.ics$`));
|
|
|
|
const second = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
expect(second.body.feed_url).toBe(first.body.feed_url); // same token, not a new one
|
|
});
|
|
|
|
it('PUT rotates: a new token works and the old one 404s', async () => {
|
|
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
const oldToken = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
|
|
|
const rot = await request(server).put('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
const newToken = rot.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
|
expect(newToken).not.toBe(oldToken);
|
|
|
|
expect((await request(server).get(`/api/feed/trip/${oldToken}.ics`)).status).toBe(404);
|
|
expect((await request(server).get(`/api/feed/trip/${newToken}.ics`)).status).toBe(200);
|
|
});
|
|
|
|
it('DELETE disables: the token is cleared, the URL 404s, and GET reports null', async () => {
|
|
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
const token = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
|
expect((await request(server).get(`/api/feed/trip/${token}.ics`)).status).toBe(200);
|
|
|
|
const del = await request(server).delete('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
expect(del.status).toBe(200);
|
|
expect(del.body).toEqual({ feed_url: null });
|
|
|
|
expect((await request(server).get(`/api/feed/trip/${token}.ics`)).status).toBe(404);
|
|
const after = await request(server).get('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
expect(after.body).toEqual({ feed_url: null });
|
|
});
|
|
|
|
it('feed URL falls back to the request host when APP_URL is unset', async () => {
|
|
delete process.env.APP_URL;
|
|
try {
|
|
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
expect(gen.body.feed_url).toMatch(/^https?:\/\/[^/]+\/api\/feed\/trip\/[0-9a-f-]+\.ics$/);
|
|
} finally {
|
|
process.env.APP_URL = BASE;
|
|
}
|
|
});
|
|
|
|
it('404 when generating for a trip the user cannot access', async () => {
|
|
const res = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(2));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Trip not found' });
|
|
});
|
|
|
|
// ── share_manage on the token routes ───────────────────────────────────────
|
|
// The token is the only credential /api/feed/trip/:token.ics asks for, so a
|
|
// plain member must not be able to read, mint, rotate or clear it. Under the
|
|
// default policy share_manage sits with the trip owner.
|
|
|
|
it('403 on all four verbs for a member without share_manage', async () => {
|
|
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run();
|
|
const member = sessionCookie(2);
|
|
|
|
for (const res of [
|
|
await request(server).get('/api/trips/5/feed/token').set('Cookie', member),
|
|
await request(server).post('/api/trips/5/feed/token').set('Cookie', member),
|
|
await request(server).put('/api/trips/5/feed/token').set('Cookie', member),
|
|
await request(server).delete('/api/trips/5/feed/token').set('Cookie', member),
|
|
]) {
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({ error: 'No permission' });
|
|
}
|
|
// Refused, not silently applied: the column is untouched.
|
|
expect((db.prepare('SELECT feed_token FROM trips WHERE id = 5').get() as { feed_token: string | null }).feed_token).toBeNull();
|
|
});
|
|
|
|
it('a non-member still gets 404 rather than 403, so the 403 is no existence oracle', async () => {
|
|
const stranger = sessionCookie(2);
|
|
for (const res of [
|
|
await request(server).get('/api/trips/5/feed/token').set('Cookie', stranger),
|
|
await request(server).put('/api/trips/5/feed/token').set('Cookie', stranger),
|
|
await request(server).delete('/api/trips/5/feed/token').set('Cookie', stranger),
|
|
]) {
|
|
expect(res.status).toBe(404);
|
|
}
|
|
});
|
|
|
|
it('a token issued before the tightening keeps working anonymously', async () => {
|
|
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
const token = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
|
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run();
|
|
|
|
// The member may no longer manage it, but existing subscriptions must not break.
|
|
expect((await request(server).delete('/api/trips/5/feed/token').set('Cookie', sessionCookie(2))).status).toBe(403);
|
|
expect((await request(server).get(`/api/feed/trip/${token}.ics`)).status).toBe(200);
|
|
});
|
|
|
|
// ── Public trip feed ───────────────────────────────────────────────────────
|
|
|
|
it('public trip feed: 200 text/calendar with injected refresh hints', async () => {
|
|
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
const token = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
|
|
|
const res = await request(server).get(`/api/feed/trip/${token}.ics`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-type']).toContain('text/calendar');
|
|
expect(res.text).toContain('REFRESH-INTERVAL;VALUE=DURATION:PT1H');
|
|
expect(res.text).toContain('X-PUBLISHED-TTL:PT1H');
|
|
expect(res.text).toContain('BEGIN:VEVENT');
|
|
expect(buildTripCalendar).toHaveBeenCalledWith(5);
|
|
});
|
|
|
|
// The @Public feed quoted cal.filename straight into Content-Disposition, so
|
|
// a non-ASCII calendar name was an unauthenticated 500 (#2165).
|
|
it('public trip feed: a non-ASCII filename folds to an ASCII header with filename*', async () => {
|
|
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
|
|
const token = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
|
|
|
// ASCII parity first: the default sample calendar keeps the exact legacy header.
|
|
const plain = await request(server).get(`/api/feed/trip/${token}.ics`);
|
|
expect(plain.headers['content-disposition']).toBe('inline; filename="sample.ics"');
|
|
|
|
buildTripCalendar.mockImplementation(() => ({ ...sampleCalendar(), filename: '沖縄カレンダー.ics' }));
|
|
const res = await request(server).get(`/api/feed/trip/${token}.ics`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-disposition']).toBe(
|
|
'inline; filename="download.ics"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC.ics',
|
|
);
|
|
});
|
|
|
|
it('public trip feed: 404 for an unknown token', async () => {
|
|
const res = await request(server).get('/api/feed/trip/00000000-0000-0000-0000-000000000000.ics');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
// ── User (all-trips) feed ──────────────────────────────────────────────────
|
|
|
|
it('user feed token: 401 without a cookie, generates with one', async () => {
|
|
expect((await request(server).get('/api/feed/user/token')).status).toBe(401);
|
|
const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1));
|
|
expect(gen.status).toBe(201);
|
|
expect(gen.body.feed_url).toMatch(new RegExp(`^${BASE}/api/feed/user/[0-9a-f-]+\\.ics$`));
|
|
});
|
|
|
|
it('all-trips feed excludes archived and >90-day-old trips', async () => {
|
|
// user 1 already owns active trip 5 (end 2099). Add an archived + a long-finished one.
|
|
db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (6, 1, 'Archived', 1, '2026-01-01', '2099-01-01')").run();
|
|
db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (7, 1, 'Old', 0, '2000-01-01', '2000-01-10')").run();
|
|
|
|
const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1));
|
|
const token = gen.body.feed_url.match(/user\/([0-9a-f-]+)\.ics$/)![1];
|
|
|
|
const res = await request(server).get(`/api/feed/user/${token}.ics`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-type']).toContain('text/calendar');
|
|
expect(res.text).toContain('REFRESH-INTERVAL;VALUE=DURATION:PT1H');
|
|
expect(res.text).toContain('X-WR-CALNAME:e2e-user');
|
|
|
|
const calledIds = buildTripCalendar.mock.calls.map((c) => c[0]).sort();
|
|
expect(calledIds).toEqual([5]); // only the active, recent trip — not 6 (archived) or 7 (old)
|
|
});
|
|
|
|
it('all-trips feed includes trips shared with the user as a member, not just owned trips', async () => {
|
|
// user 1 owns active trip 5. Trip 8 is owned by user 2 but shared with user 1 as a
|
|
// member — "All Trips" must include it, mirroring the single-trip feed's member access.
|
|
db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (8, 2, 'Shared', 0, '2026-01-01', '2099-01-01')").run();
|
|
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (8, 1)').run();
|
|
|
|
const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1));
|
|
const token = gen.body.feed_url.match(/user\/([0-9a-f-]+)\.ics$/)![1];
|
|
|
|
const res = await request(server).get(`/api/feed/user/${token}.ics`);
|
|
expect(res.status).toBe(200);
|
|
|
|
const calledIds = buildTripCalendar.mock.calls.map((c) => c[0]).sort();
|
|
expect(calledIds).toEqual([5, 8]); // owned trip 5 AND member trip 8
|
|
});
|
|
|
|
it('public user feed: 404 for an unknown token', async () => {
|
|
const res = await request(server).get('/api/feed/user/00000000-0000-0000-0000-000000000000.ics');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('all-trips feed carries VTIMEZONE blocks so TZID references resolve (#1453)', async () => {
|
|
// A per-trip calendar whose event references a zone via TZID and defines it.
|
|
const PARIS_VTIMEZONE =
|
|
'BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n' +
|
|
'TZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nTZNAME:Europe/Paris\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n';
|
|
buildTripCalendar.mockImplementation(() => ({
|
|
calName: 'Zoned',
|
|
filename: 'zoned.ics',
|
|
timezones: new Map([['Europe/Paris', PARIS_VTIMEZONE]]),
|
|
events: [
|
|
'BEGIN:VEVENT\r\nUID:trek-res-1@trek\r\nDTSTAMP:20260101T000000Z\r\n' +
|
|
'DTSTART;TZID=Europe/Paris:20260602T090000\r\nSUMMARY:Flight\r\nEND:VEVENT\r\n',
|
|
],
|
|
}));
|
|
|
|
const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1));
|
|
const token = gen.body.feed_url.match(/user\/([0-9a-f-]+)\.ics$/)![1];
|
|
|
|
const res = await request(server).get(`/api/feed/user/${token}.ics`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.text).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris');
|
|
expect(res.text).toContain('DTSTART;TZID=Europe/Paris:20260602T090000');
|
|
// VTIMEZONE must precede the VEVENT that references it.
|
|
expect(res.text.indexOf('BEGIN:VTIMEZONE')).toBeLessThan(res.text.indexOf('BEGIN:VEVENT'));
|
|
});
|
|
});
|