From 39c8a2235261a2733937a6f9e0549e18126be433 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:12:26 +0200 Subject: [PATCH 01/25] fix(offline): replay queued writes an interrupted flush left behind A flush marks each row 'syncing' before it sends, and only ever picks up rows that are 'pending'. Kill the tab mid-request - the OS evicting a PWA does exactly this - and the row stays 'syncing' forever: it keeps counting towards the pending badge, but nothing ever retries it, fails it or shows it as a conflict. Anything queued behind it that depends on it later fails with 'unresolved temp id'. Stamp the row when it goes in flight and reclaim it on the next flush. The axios instance times out at 8s, so a minute is well past anything that could still be running, and the mutation id doubles as the idempotency key, so replaying a write the server already applied is answered from its cache. --- client/src/db/offlineDb.ts | 7 ++++ client/src/sync/mutationQueue.ts | 22 ++++++++++- client/tests/unit/sync/mutationQueue.test.ts | 41 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 02328f250..a072bf761 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -51,6 +51,13 @@ export interface QueuedMutation { conflictServer?: unknown; /** When the conflict was detected (for ordering / display). */ conflictAt?: number; + /** + * When the row was marked 'syncing'. Only meaningful while it is — the flush + * that set it clears the row on success or moves it off 'syncing' on failure. + * A stamp that outlives its flush is how a killed tab is recognised on the + * next one (see mutationQueue's STUCK_SYNCING_MS). + */ + syncingSince?: number; } export interface SyncMeta { diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 43112821b..ab7035107 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -39,6 +39,13 @@ export function generateUUID(): string { } let _flushing = false +// A row sits on 'syncing' only while its request is in flight, and the shared +// axios instance times out at 8s. Anything still 'syncing' a minute later +// belongs to a flush that never reached its catch — the tab was killed, the PWA +// was evicted, the device slept mid-request. flush() only ever selects +// 'pending', so without recovery that row is stranded: counted in the badge, +// never replayed, and blocking every dependent behind it. +const STUCK_SYNCING_MS = 60_000 // Monotonically increasing timestamp so same-millisecond enqueues // still get a deterministic FIFO order when sorted by createdAt. let _lastTs = 0 @@ -121,14 +128,25 @@ export const mutationQueue = { // without its base token, so one more pass overwrites the server cleanly. let needsRetry = false try { + // Reclaim what an interrupted flush left behind before picking up work. + // Replaying is safe: the mutation id doubles as the X-Idempotency-Key, so + // a write the server already applied is answered from its replay cache. + const stuckBefore = Date.now() - STUCK_SYNCING_MS + await offlineDb.mutationQueue + .where('status') + .equals('syncing') + .filter(m => (m.syncingSince ?? 0) < stuckBefore) + .modify(m => { m.status = 'pending'; m.syncingSince = undefined }) + const pending = await offlineDb.mutationQueue .where('status') .equals('pending') .sortBy('createdAt') for (const mutation of pending) { - // Mark as syncing so UI can show progress - await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' }) + // Mark as syncing so UI can show progress. The stamp is what lets the + // next flush tell an in-flight row from one a killed tab abandoned. + await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing', syncingSince: Date.now() }) // Resolve a temp-id reference now that earlier CREATEs in this flush // may have completed (FIFO order guarantees the CREATE ran first). diff --git a/client/tests/unit/sync/mutationQueue.test.ts b/client/tests/unit/sync/mutationQueue.test.ts index 8e7868acf..19cfc6a5d 100644 --- a/client/tests/unit/sync/mutationQueue.test.ts +++ b/client/tests/unit/sync/mutationQueue.test.ts @@ -241,6 +241,47 @@ describe('mutationQueue.flush — offline guard', () => { // ── pending / pendingCount ──────────────────────────────────────────────────── +describe('mutationQueue.flush — interrupted flush recovery', () => { + it('replays a row an earlier flush abandoned on syncing', async () => { + let seen = 0; + server.use( + http.post('/api/trips/1/places', () => { + seen += 1; + return HttpResponse.json({ place: buildPlace({ id: 42 }) }); + }), + ); + + // What a killed tab leaves behind: marked syncing, stamped long enough ago + // that no live request could still be in flight. + const id = generateUUID(); + await offlineDb.mutationQueue.add({ + id, tripId: 1, method: 'POST', url: '/trips/1/places', + body: { name: 'Eiffel Tower' }, resource: 'places', + createdAt: Date.now() - 600_000, status: 'syncing', attempts: 0, lastError: null, + syncingSince: Date.now() - 600_000, + }); + + await mutationQueue.flush(); + + expect(seen).toBe(1); + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined(); + }); + + it('leaves a freshly marked syncing row alone (a concurrent flush owns it)', async () => { + const id = generateUUID(); + await offlineDb.mutationQueue.add({ + id, tripId: 1, method: 'POST', url: '/trips/1/places', + body: { name: 'Eiffel Tower' }, resource: 'places', + createdAt: Date.now(), status: 'syncing', attempts: 0, lastError: null, + syncingSince: Date.now(), + }); + + await mutationQueue.flush(); + + expect((await offlineDb.mutationQueue.get(id))!.status).toBe('syncing'); + }); +}); + describe('mutationQueue.pending', () => { it('returns pending mutations for a trip', async () => { const id1 = generateUUID(); From d91eda690a88a5b1066862073b2bf25ee5ed2bff Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:12:46 +0200 Subject: [PATCH 02/25] fix(reservations): keep every id in a booking inside its own trip reservation_edit on the trip in the URL says the caller may write here. It says nothing about the ids they put in the body, and the write contracts are open records, so day_id, place_id, assignment_id and accommodation_id all arrived unchecked. A booking created in one trip could carry another trip's accommodation_id, and deleting it ran the cascade against that foreign row. The MCP tools have refused foreign ids since they were written. Put the same check where every writer reaches it, and scope the two day_accommodations statements to the trip so a row stored before this still cannot be followed out of it. --- .../reservations/reservations.controller.ts | 17 +++++ .../nest/reservations/reservations.service.ts | 52 +++++++++++++++- .../unit/nest/reservations.controller.test.ts | 25 ++++++++ .../unit/nest/reservations.service.test.ts | 62 +++++++++++++++++++ 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index 13b5987b9..ccde3f7e1 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -68,6 +68,7 @@ export class ReservationsController { @Headers('x-socket-id') socketId?: string, ) { const body = rawBody as ReservationBody & { title: string }; + this.rejectForeignReferences(tripId, body); const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never); if (accommodationCreated) { this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId); @@ -107,6 +108,7 @@ export class ReservationsController { if (!current) { throw new HttpException({ error: 'Reservation not found' }, 404); } + this.rejectForeignReferences(tripId, body); const { reservation, accommodationChanged } = this.reservations.update(id, tripId, body as never, current as never); if (accommodationChanged) { this.reservations.broadcast(tripId, 'accommodation:updated', {}, socketId); @@ -162,4 +164,19 @@ export class ReservationsController { this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || ''); return { success: true }; } + + /** + * The write contracts are open records, so day_id, place_id, assignment_id and + * accommodation_id arrive unvalidated. reservation_edit on :tripId says the + * caller may write HERE — it says nothing about the ids they put in the body, + * and a foreign accommodation_id used to be stored verbatim and deleted with + * the reservation. The MCP tools have refused foreign ids since they were + * written; this is the REST half of the same rule. + */ + private rejectForeignReferences(tripId: string, body: ReservationBody): void { + const offenders = this.reservations.referencesOutsideTrip(tripId, body as never); + if (offenders.length > 0) { + throw new HttpException({ error: `Not part of this trip: ${offenders.join(', ')}` }, 400); + } + } } diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 15ebbaa2e..67960dca3 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -432,6 +432,45 @@ export class ReservationsService { return this.reads.getReservationWithJoins(id); } + /** + * Name every id in the body that points outside this trip. + * + * Permission on the trip in the URL does not cover the body: it carries ids + * of its own, and a reservation happily stores a foreign accommodation_id, + * whose delete cascade then follows it out of the caller's own trip. The MCP + * tools have validated their referenced ids since they were written; this is + * the same check where every writer can reach it. + * + * Returns the offending field names, empty when the body is clean. + */ + referencesOutsideTrip(tripId: string | number, data: CreateReservationData | UpdateReservationData): string[] { + const offenders: string[] = []; + const inTrip = (table: 'days' | 'places' | 'day_accommodations', id: unknown) => + !!this.db.get(`SELECT id FROM ${table} WHERE id = ? AND trip_id = ?`, id, tripId); + + const check = (field: string, ok: boolean) => { if (!ok) offenders.push(field); }; + + if (data.day_id != null) check('day_id', inTrip('days', data.day_id)); + if (data.end_day_id != null) check('end_day_id', inTrip('days', data.end_day_id)); + if (data.place_id != null) check('place_id', inTrip('places', data.place_id)); + if (data.accommodation_id != null) check('accommodation_id', inTrip('day_accommodations', data.accommodation_id)); + if (data.assignment_id != null) { + check('assignment_id', !!this.db.get( + 'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?', + data.assignment_id, tripId, + )); + } + + const acc = data.create_accommodation; + if (acc) { + if (acc.place_id != null) check('create_accommodation.place_id', inTrip('places', acc.place_id)); + if (acc.start_day_id != null) check('create_accommodation.start_day_id', inTrip('days', acc.start_day_id)); + if (acc.end_day_id != null) check('create_accommodation.end_day_id', inTrip('days', acc.end_day_id)); + } + + return offenders; + } + /** The accommodation insert, the reservation insert, the endpoint save and * the metadata sync are one logical write — all-or-nothing. */ create(tripId: string | number, data: CreateReservationData): { reservation: ReservationRow; accommodationCreated: boolean } { @@ -583,7 +622,9 @@ export class ReservationsService { // Update or create accommodation for hotel reservations let resolvedAccId: number | null = accommodation_id !== undefined ? (accommodation_id || null) : (current.accommodation_id ?? null); if (resolvedAccId) { - const accExists = this.db.get('SELECT id FROM day_accommodations WHERE id = ?', resolvedAccId); + // Scoped to the trip on purpose: an id belonging to someone else's trip + // must read as absent here, not as an accommodation to write through to. + const accExists = this.db.get('SELECT id FROM day_accommodations WHERE id = ? AND trip_id = ?', resolvedAccId, tripId); if (!accExists) resolvedAccId = null; } if (type === 'hotel' && create_accommodation) { @@ -720,8 +761,13 @@ export class ReservationsService { let accommodationDeleted = false; if (reservation.accommodation_id) { - this.db.run('DELETE FROM day_accommodations WHERE id = ?', reservation.accommodation_id); - accommodationDeleted = true; + // trip_id in the WHERE, not just the reservation's own scope: a row + // written before referencesOutsideTrip existed can still carry a + // foreign accommodation_id, and the cascade must not follow it. + const removed = this.db.run( + 'DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?', reservation.accommodation_id, tripId, + ); + accommodationDeleted = removed.changes > 0; } const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id); diff --git a/server/tests/unit/nest/reservations.controller.test.ts b/server/tests/unit/nest/reservations.controller.test.ts index 840435c9d..4c994d001 100644 --- a/server/tests/unit/nest/reservations.controller.test.ts +++ b/server/tests/unit/nest/reservations.controller.test.ts @@ -15,6 +15,7 @@ function makeService(overrides: Partial = {}): Reservations return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), + referencesOutsideTrip: vi.fn().mockReturnValue([]), broadcast: vi.fn(), syncBudgetOnCreate: vi.fn(), syncBudgetOnUpdate: vi.fn(), @@ -55,6 +56,18 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock'); expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging'); }); + + it('400s on a body id belonging to another trip, without writing', () => { + const create = vi.fn(); + const svc = makeService({ + create, + referencesOutsideTrip: vi.fn().mockReturnValue(['accommodation_id']), + } as Partial); + const body = { title: 'Hotel', accommodation_id: 4711 }; + expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) + .toEqual({ status: 400, body: { error: 'Not part of this trip: accommodation_id' } }); + expect(create).not.toHaveBeenCalled(); + }); }); describe('PUT /positions', () => { @@ -87,6 +100,18 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese expect(syncBudgetOnUpdate).toHaveBeenCalledWith('5', '9', '', undefined, 'Old', 'lodging', { total_price: 50 }, 'sock'); expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Old', 'lodging'); }); + + it('400s on a body id belonging to another trip, without writing', () => { + const update = vi.fn(); + const svc = makeService({ + getReservation: vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' }), + update, + referencesOutsideTrip: vi.fn().mockReturnValue(['day_id', 'place_id']), + } as Partial); + expect(thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { day_id: 1, place_id: 2 }))) + .toEqual({ status: 400, body: { error: 'Not part of this trip: day_id, place_id' } }); + expect(update).not.toHaveBeenCalled(); + }); }); describe('PUT /:id/travelers', () => { diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index 60f0d94ed..89f4310ea 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -816,3 +816,65 @@ describe('ReservationsService — quirk fixes (post-fold)', () => { expect(testDb.prepare('SELECT title FROM reservations WHERE id = ?').get(res.id)).toEqual({ title: 'Old' }); }); }); + +describe('ReservationsService — referenced ids stay inside the trip', () => { + /** Attacker's trip and a victim trip they are not a member of. */ + function twoTrips() { + const { user: attacker } = createUser(testDb); + const { user: victim } = createUser(testDb, { email: 'victim@example.test' }); + const mine = createTrip(testDb, attacker.id, { start_date: '2030-05-01', end_date: '2030-05-03' }); + const theirs = createTrip(testDb, victim.id, { start_date: '2030-05-01', end_date: '2030-05-03' }); + return { mine, theirs }; + } + + it('RESV-SCOPE-001: names every foreign id in the body', () => { + const { mine, theirs } = twoTrips(); + const foreignPlace = createPlace(testDb, theirs.id); + const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number }; + const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id); + + expect(svc.referencesOutsideTrip(String(mine.id), { + title: 'x', day_id: foreignDay.id, place_id: foreignPlace.id, accommodation_id: foreignAcc.id, + })).toEqual(['day_id', 'place_id', 'accommodation_id']); + }); + + it('RESV-SCOPE-002: passes a body whose ids all live in the trip', () => { + const { mine } = twoTrips(); + const place = createPlace(testDb, mine.id); + const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(mine.id) as { id: number }; + + expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x', day_id: day.id, place_id: place.id })).toEqual([]); + expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x' })).toEqual([]); + }); + + it('RESV-SCOPE-003: a stored foreign accommodation_id is not deleted with the reservation', () => { + const { mine, theirs } = twoTrips(); + const foreignPlace = createPlace(testDb, theirs.id); + const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number }; + const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id); + // A row from before the check existed: it already carries the foreign id. + const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' }); + testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(foreignAcc.id, res.id); + + const { accommodationDeleted } = svc.remove(String(res.id), String(mine.id)); + + expect(accommodationDeleted).toBe(false); + expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(foreignAcc.id)).toBeTruthy(); + }); + + it('RESV-SCOPE-004: an update carrying a foreign accommodation_id does not write through to it', () => { + const { mine, theirs } = twoTrips(); + const foreignPlace = createPlace(testDb, theirs.id); + const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number }; + const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id, { check_in: '14:00' }); + const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' }); + const current = svc.getReservation(String(res.id), String(mine.id))!; + + svc.update(String(res.id), String(mine.id), { + accommodation_id: foreignAcc.id, metadata: { check_in_time: '23:00' }, + } as never, current); + + expect(testDb.prepare('SELECT check_in FROM day_accommodations WHERE id = ?').get(foreignAcc.id)).toEqual({ check_in: '14:00' }); + expect(testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ accommodation_id: null }); + }); +}); From 3f8a7a1ea9096b5f7233d492409e5ae6941f900f Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:34:45 +0200 Subject: [PATCH 03/25] fix(feeds): keep subscribable calendars working under default-deny The ICS feed routes are documented as public - the secret token in the URL is the credential - but nothing in the code said so, and the guard this release makes global is default-deny. Every calendar client subscribed to /api/feed/trip/.ics would have started getting 401 on upgrade, and a calendar app that stops fetching just goes quiet. The e2e suite asserted anonymous access the whole time. It booted [DatabaseModule, RealtimeModule, FeedsModule], and the APP_GUARDs live in AppModule, so it was asserting against an app that had no opinion on anonymity. It boots the real guard stack now, and fails without the fix. The boot ratchet could not have caught this either: it only looked at routes carrying @Public(), and GlobalAuthGuard stands down for ANY declared chain - so a chain of guards that refuse a caller but never resolve one answers strangers, invisibly. Split the inventory in two and write that half down as well. It turns up two routes nobody had listed: the passkey login ceremony, which genuinely runs before a session exists. --- .../src/nest/common/validate-route-guards.ts | 72 ++++++++++++++++--- server/src/nest/feeds/feeds.controller.ts | 2 + server/tests/e2e/feeds.e2e.test.ts | 17 ++++- .../tests/unit/nest/route-inventory.test.ts | 24 ++++++- 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/server/src/nest/common/validate-route-guards.ts b/server/src/nest/common/validate-route-guards.ts index 73222ae92..e566e2014 100644 --- a/server/src/nest/common/validate-route-guards.ts +++ b/server/src/nest/common/validate-route-guards.ts @@ -2,14 +2,27 @@ import { METHOD_METADATA, PATH_METADATA, GUARDS_METADATA } from '@nestjs/common/ import { ModulesContainer } from '@nestjs/core'; import type { INestApplication } from '@nestjs/common'; import { IS_PUBLIC, OPTIONAL_AUTH } from '../auth/public.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CookieAuthGuard } from '../auth/cookie-auth.guard'; +import { OptionalJwtGuard } from '../auth/optional-jwt.guard'; export interface RouteGuardEntry { /** `ControllerClass.methodName` */ id: string; /** How the route is covered, for the failure message. */ - cover: 'public' | 'optional-auth' | 'declared-guards'; + cover: 'public' | 'optional-auth' | 'declared-guards' | 'declared-guards-anonymous'; } +/** + * The guards that actually resolve a caller. GlobalAuthGuard stands down for ANY + * declared chain, so a chain built only from other guards answers anonymously — + * that is what separates 'declared-guards' from 'declared-guards-anonymous'. + * TripAccessGuard and friends are absent on purpose: they refuse a request with + * no req.user, but they never resolve one, so they only ever appear behind one + * of these three. + */ +const AUTHENTICATING_GUARDS: unknown[] = [JwtAuthGuard, CookieAuthGuard, OptionalJwtGuard]; + /** * Boot-time gate: every registered route must be authenticated, or say why not. * @@ -23,7 +36,9 @@ export interface RouteGuardEntry { * declared @UseGuards chain (on the handler or the class). Everything else falls * to GlobalAuthGuard, which is fine — that is the default — so this gate does * not fail on it. What it reports is the inventory, so the exempt set is a list - * somebody can read. + * somebody can read. A declared chain is split in two, because GlobalAuthGuard + * stands down on any chain at all: one that authenticates, and one that answers + * strangers. */ export function collectRouteGuards(app: INestApplication): RouteGuardEntry[] { const container = app.get(ModulesContainer, { strict: false }); @@ -53,7 +68,10 @@ export function collectRouteGuards(app: INestApplication): RouteGuardEntry[] { if (isPublic) entries.push({ id, cover: 'public' }); else if (isOptional) entries.push({ id, cover: 'optional-auth' }); - else if (classGuards.length > 0 || handlerGuards.length > 0) entries.push({ id, cover: 'declared-guards' }); + else if (classGuards.length > 0 || handlerGuards.length > 0) { + const authenticates = [...handlerGuards, ...classGuards].some((g) => AUTHENTICATING_GUARDS.includes(g)); + entries.push({ id, cover: authenticates ? 'declared-guards' : 'declared-guards-anonymous' }); + } } } } @@ -62,9 +80,11 @@ export function collectRouteGuards(app: INestApplication): RouteGuardEntry[] { } /** - * The routes that answer without a session. Reviewed as a list on purpose: this - * is the whole anonymous surface of the server in one place, and a diff that - * grows it is a diff that widens what strangers can reach. + * The routes that answer without a session because they carry @Public(). + * Reviewed as a list on purpose: together with + * ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST this is the whole anonymous surface of the + * server in one place, and a diff that grows either is a diff that widens what + * strangers can reach. * * Entries are `ControllerClass.methodName`. Sorted, no duplicates. */ @@ -89,6 +109,10 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [ 'FeaturesController.features', // The container/uptime probe. 'FeaturesController.health', + // Subscribable ICS feeds. The token in the path is the credential, and the + // calendar client polling it has no TREK session to send. + 'FeedsPublicController.tripFeed', + 'FeedsPublicController.userFeed', // The download link carries its own short-lived token. 'FilesDownloadController.download', 'HelpController.asset', @@ -119,17 +143,38 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [ ]; /** - * Throws when a route carries @Public() without being listed above, or when a + * The other half of the anonymous surface: routes with no @Public() that still + * answer without a session, because the guards they declare never resolve a + * caller. GlobalAuthGuard stands down on a declared chain, so this is not a + * hole the gate should close — it is one it has to keep visible. + */ +export const ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST: string[] = [ + // The passkey login ceremony, which by definition runs before a session + // exists. PasskeyEnabledGuard only 404s when the admin toggle is off. + 'PasskeyController.loginOptions', + 'PasskeyController.loginVerify', +]; + +/** + * Throws when a route answers anonymously without being listed above, or when a * listed route no longer exists. Stale entries fail too, for the same reason the * body-contract gate fails on them: a list nobody prunes stops being a list. */ -export function validateRouteGuards(app: INestApplication, allowList: string[] = PUBLIC_ROUTE_ALLOW_LIST): void { +export function validateRouteGuards( + app: INestApplication, + allowList: string[] = PUBLIC_ROUTE_ALLOW_LIST, + anonymousGuardedAllowList: string[] = ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST, +): void { const entries = collectRouteGuards(app); const publicIds = entries.filter((e) => e.cover === 'public').map((e) => e.id); + const anonymousIds = entries.filter((e) => e.cover === 'declared-guards-anonymous').map((e) => e.id); const allowed = new Set(allowList); + const anonymousAllowed = new Set(anonymousGuardedAllowList); const undeclared = publicIds.filter((id) => !allowed.has(id)); const stale = allowList.filter((id) => !publicIds.includes(id)); + const undeclaredAnonymous = anonymousIds.filter((id) => !anonymousAllowed.has(id)); + const staleAnonymous = anonymousGuardedAllowList.filter((id) => !anonymousIds.includes(id)); const problems: string[] = []; if (undeclared.length > 0) { @@ -142,6 +187,17 @@ export function validateRouteGuards(app: INestApplication, allowList: string[] = `PUBLIC_ROUTE_ALLOW_LIST entries that are no longer @Public():\n ${stale.join('\n ')}`, ); } + if (undeclaredAnonymous.length > 0) { + problems.push( + 'route(s) whose declared guard chain never authenticates, but not in ' + + `ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST:\n ${undeclaredAnonymous.join('\n ')}`, + ); + } + if (staleAnonymous.length > 0) { + problems.push( + `ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST entries that now authenticate or are gone:\n ${staleAnonymous.join('\n ')}`, + ); + } if (problems.length > 0) { throw new Error( `Anonymous route surface changed.\n\n${problems.join('\n\n')}\n\n` + diff --git a/server/src/nest/feeds/feeds.controller.ts b/server/src/nest/feeds/feeds.controller.ts index 7526e8fe3..9bbc67649 100644 --- a/server/src/nest/feeds/feeds.controller.ts +++ b/server/src/nest/feeds/feeds.controller.ts @@ -16,6 +16,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { User } from '../../types'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; +import { Public } from '../auth/public.decorator'; // Resolve the public origin used to build feed URLs. APP_URL wins — it is the // canonical externally-reachable URL behind a reverse proxy. When it is unset @@ -35,6 +36,7 @@ function resolveFeedBase(req: Request): string { * The secret token in the URL acts as the access credential. */ @Controller('api/feed') +@Public('the secret token in the feed URL is the credential; calendar clients never send a session') export class FeedsPublicController { constructor(private readonly feeds: FeedsService) {} diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index 219706150..25520e195 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -18,6 +18,7 @@ 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(() => { @@ -73,6 +74,9 @@ 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'; const BASE = 'https://trek.example.test'; @@ -82,7 +86,18 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { let prevAppUrl: string | undefined; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, FeedsModule] }).compile(); + // 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: [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()); diff --git a/server/tests/unit/nest/route-inventory.test.ts b/server/tests/unit/nest/route-inventory.test.ts index 2ff8a5dd6..9864908d7 100644 --- a/server/tests/unit/nest/route-inventory.test.ts +++ b/server/tests/unit/nest/route-inventory.test.ts @@ -18,7 +18,12 @@ vi.mock('../../../src/db/database', async () => { import { Test } from '@nestjs/testing'; import { AppModule } from '../../../src/nest/app.module'; import { DatabaseService } from '../../../src/nest/database/database.service'; -import { collectRouteGuards, PUBLIC_ROUTE_ALLOW_LIST, validateRouteGuards } from '../../../src/nest/common/validate-route-guards'; +import { + ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST, + collectRouteGuards, + PUBLIC_ROUTE_ALLOW_LIST, + validateRouteGuards, +} from '../../../src/nest/common/validate-route-guards'; async function buildApp() { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) @@ -70,8 +75,23 @@ describe('route guard inventory', () => { const entries = collectRouteGuards(app); // The inventory only lists routes that opted out of the default. Anything // absent from it falls to GlobalAuthGuard, which is the safe direction. - expect(entries.every((e) => ['public', 'optional-auth', 'declared-guards'].includes(e.cover))).toBe(true); + const covers = ['public', 'optional-auth', 'declared-guards', 'declared-guards-anonymous']; + expect(entries.every((e) => covers.includes(e.cover))).toBe(true); expect(entries.filter((e) => e.cover === 'public').length).toBe(PUBLIC_ROUTE_ALLOW_LIST.length); + expect(entries.filter((e) => e.cover === 'declared-guards-anonymous').map((e) => e.id)) + .toEqual(ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST); + } finally { + await app.close(); + } + }); + + it('ROUTES-006: a guard chain that never authenticates fails the gate unless it is written down', async () => { + const app = await buildApp(); + try { + expect(() => validateRouteGuards(app, PUBLIC_ROUTE_ALLOW_LIST, [])) + .toThrow(ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST[0]); + expect(() => validateRouteGuards(app, PUBLIC_ROUTE_ALLOW_LIST, [...ANONYMOUS_GUARDED_ROUTE_ALLOW_LIST, 'GhostController.anonymous'])) + .toThrow('GhostController.anonymous'); } finally { await app.close(); } From b163c1e05d834b6922ee64502f79e8fe2e12f61d Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:34:53 +0200 Subject: [PATCH 04/25] fix(server): hold DELETE bodies to the same contract as every other write The body-contract boot gate only looked at POST/PUT/PATCH, so a DELETE that reads a body slipped past it. Two do; one was already typed, the other read photo_id straight off an untyped Record. Give it a contract from shared like everything else, then add DELETE to the gate so the next one cannot repeat it. --- .../src/nest/common/validate-body-contracts.ts | 4 ++++ server/src/nest/memories/memories.dto.ts | 2 ++ server/src/nest/memories/unified.controller.ts | 4 ++-- .../unit/nest/validate-body-contracts.test.ts | 18 ++++++++++++++++-- shared/src/memories/memories.schema.ts | 6 ++++++ 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/server/src/nest/common/validate-body-contracts.ts b/server/src/nest/common/validate-body-contracts.ts index dccd22f60..5e093948f 100644 --- a/server/src/nest/common/validate-body-contracts.ts +++ b/server/src/nest/common/validate-body-contracts.ts @@ -10,6 +10,10 @@ const MUTATION_METHODS = new Set([ RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH, + // DELETE belongs here too. It is rare for one to read a body, but the two that + // do are writes like any other, and leaving the method out meant their bodies + // reached the handler with nothing having looked at them. + RequestMethod.DELETE, ]); /** diff --git a/server/src/nest/memories/memories.dto.ts b/server/src/nest/memories/memories.dto.ts index 1f978ead5..d0303a19a 100644 --- a/server/src/nest/memories/memories.dto.ts +++ b/server/src/nest/memories/memories.dto.ts @@ -5,6 +5,7 @@ import { immichSearchSchema, immichSettingsSchema, immichTestSchema, + removeTripPhotoSchema, setTripPhotoSharingSchema, synologySearchSchema, synologySettingsSchema, @@ -31,4 +32,5 @@ export class SynologySearchDto extends createZodDto(synologySearchSchema) {} export class AddTripPhotosDto extends createZodDto(addTripPhotosSchema) {} export class SetTripPhotoSharingDto extends createZodDto(setTripPhotoSharingSchema) {} +export class RemoveTripPhotoDto extends createZodDto(removeTripPhotoSchema) {} export class CreateAlbumLinkDto extends createZodDto(createAlbumLinkSchema) {} diff --git a/server/src/nest/memories/unified.controller.ts b/server/src/nest/memories/unified.controller.ts index 3810af95a..ce5524fcd 100644 --- a/server/src/nest/memories/unified.controller.ts +++ b/server/src/nest/memories/unified.controller.ts @@ -5,7 +5,7 @@ import type { Selection } from './memories.helpers'; import { MemoriesService } from './memories.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { AddTripPhotosDto, CreateAlbumLinkDto, SetTripPhotoSharingDto } from './memories.dto'; +import { AddTripPhotosDto, CreateAlbumLinkDto, RemoveTripPhotoDto, SetTripPhotoSharingDto } from './memories.dto'; /** * /api/integrations/memories/unified — provider-agnostic trip photo + album-link @@ -69,7 +69,7 @@ export class UnifiedMemoriesController { async removePhoto( @CurrentUser() user: User, @Param('tripId') tripId: string, - @Body() body: Record, + @Body() body: RemoveTripPhotoDto, @Res() res: Response, ): Promise { const result = this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id)); diff --git a/server/tests/unit/nest/validate-body-contracts.test.ts b/server/tests/unit/nest/validate-body-contracts.test.ts index 96e4a7829..f17c6474e 100644 --- a/server/tests/unit/nest/validate-body-contracts.test.ts +++ b/server/tests/unit/nest/validate-body-contracts.test.ts @@ -5,7 +5,7 @@ * stale allow-list entries must refuse boot too (the list only ratchets down). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { Body, Controller, Get, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Post, Put } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import type { INestApplication } from '@nestjs/common'; import { createZodDto } from 'nestjs-zod'; @@ -41,6 +41,14 @@ class BadController { } } +@Controller('gate-test/delete-body') +class DeleteBodyController { + @Delete() + remove(@Body() body: Record) { + return body; + } +} + @Controller('gate-test/subfield') class SubfieldController { @Post() @@ -60,15 +68,17 @@ describe('validateBodyContracts (fail-closed boot gate)', () => { let goodApp: INestApplication; let badApp: INestApplication; let subfieldApp: INestApplication; + let deleteBodyApp: INestApplication; beforeAll(async () => { goodApp = await buildApp([GoodController]); badApp = await buildApp([GoodController, BadController]); subfieldApp = await buildApp([SubfieldController]); + deleteBodyApp = await buildApp([DeleteBodyController]); }); afterAll(async () => { - await Promise.all([goodApp.close(), badApp.close(), subfieldApp.close()]); + await Promise.all([goodApp.close(), badApp.close(), subfieldApp.close(), deleteBodyApp.close()]); }); it('GATE-001 — passes when every mutation body is a createZodDto class', () => { @@ -83,6 +93,10 @@ describe('validateBodyContracts (fail-closed boot gate)', () => { expect(() => validateBodyContracts(subfieldApp, [])).toThrow(/SubfieldController\.reorder/); }); + it('GATE-007 — a DELETE that reads a body is held to the same contract', () => { + expect(() => validateBodyContracts(deleteBodyApp, [])).toThrow(/DeleteBodyController\.remove/); + }); + it('GATE-004 — an allow-listed legacy route passes', () => { expect(() => validateBodyContracts(badApp, ['BadController.update'])).not.toThrow(); }); diff --git a/shared/src/memories/memories.schema.ts b/shared/src/memories/memories.schema.ts index 0e4ca299e..b1ae1bfe7 100644 --- a/shared/src/memories/memories.schema.ts +++ b/shared/src/memories/memories.schema.ts @@ -92,6 +92,11 @@ export const setTripPhotoSharingSchema = z.looseObject({ shared: z.unknown().optional(), }); +/** The photo to unlink from the trip. Same shape the sharing toggle takes. */ +export const removeTripPhotoSchema = z.looseObject({ + photo_id: looseNumber, +}); + export const createAlbumLinkSchema = z.looseObject({ provider: z.unknown().optional(), album_id: z.unknown().optional(), @@ -108,4 +113,5 @@ export type SynologySearchInput = z.infer; export type PhotoSelectionInput = z.infer; export type AddTripPhotosInput = z.infer; export type SetTripPhotoSharingInput = z.infer; +export type RemoveTripPhotoInput = z.infer; export type CreateAlbumLinkInput = z.infer; From 97ebe23d3bd916157a3f682fbdfd8116f6566cf7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:47:22 +0200 Subject: [PATCH 05/25] fix(server): bound every outbound fetch The rule is a timeout, a size cap and boundary validation on anything that leaves the process, and several call sites had only the timeout. A link preview, a maps URL resolve, a wiki fallback and a holiday lookup all read the whole body into memory, so a caller who picks the URL picks how much memory the server spends - a few concurrent multi-hundred-megabyte responses is the whole heap. Two of them also fetched more than their name suggests. resolve-url fell through to a full page GET for any host once it found no coordinates, which makes the server a general-purpose outbound proxy wearing its own IP. The weather URLs took lat/lng straight from the caller into the query string. readCapped/exceedsDeclaredLength collect the pattern transit.service.ts and the plugin installer had each grown separately, so there is one of it now. Callers that only scan the head of a document (og: tags, a coordinate regex) keep the truncated bytes and find fewer fields; callers that must have the whole thing reject it. --- server/src/nest/admin/admin.service.ts | 9 +- server/src/nest/help/wiki.ts | 19 ++- server/src/nest/maps/maps.service.ts | 22 +++- .../notifications/transports/ntfy.service.ts | 6 +- server/src/nest/unsplash/unsplash.service.ts | 15 ++- server/src/nest/vacay/vacay.service.ts | 25 +++- server/src/nest/weather/weather.impl.ts | 53 ++++++-- server/src/utils/cappedFetch.ts | 78 +++++++++++ server/tests/unit/nest/admin.service.test.ts | 25 ++++ server/tests/unit/nest/maps.service.test.ts | 30 +++++ .../unit/nest/notification-transports.test.ts | 27 ++++ .../nest/unsplash/unsplash.service.test.ts | 55 ++++++++ server/tests/unit/nest/vacay.service.test.ts | 35 +++++ .../unit/nest/weather/weather.impl.test.ts | 38 ++++++ server/tests/unit/nest/wiki.test.ts | 21 +++ server/tests/unit/utils/cappedFetch.test.ts | 121 ++++++++++++++++++ 16 files changed, 551 insertions(+), 28 deletions(-) create mode 100644 server/src/utils/cappedFetch.ts create mode 100644 server/tests/unit/utils/cappedFetch.test.ts diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 340a841f6..9cb35f9ab 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -421,9 +421,12 @@ export class AdminService { } async getGithubReleases(perPage: string = '10', page: string = '1') { - const data = await this.fetchGithub( - `https://api.github.com/repos/liketrek/TREK/releases?per_page=${perPage}&page=${page}`, - ); + // The query arrives as raw strings from the admin UI; clamp to what the + // GitHub API accepts instead of interpolating them into the URL as given. + const per = Math.min(Math.max(Number.parseInt(perPage, 10) || 10, 1), 100); + const pg = Math.max(Number.parseInt(page, 10) || 1, 1); + const qs = new URLSearchParams({ per_page: String(per), page: String(pg) }); + const data = await this.fetchGithub(`https://api.github.com/repos/liketrek/TREK/releases?${qs}`); return Array.isArray(data) ? data : []; } diff --git a/server/src/nest/help/wiki.ts b/server/src/nest/help/wiki.ts index 757f25905..d35465dba 100644 --- a/server/src/nest/help/wiki.ts +++ b/server/src/nest/help/wiki.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fs } from 'fs'; import path from 'path'; import { readEnv } from '../../app-config'; +import { exceedsDeclaredLength, readCapped, readCappedText } from '../../utils/cappedFetch'; /** * In-app Help/Wiki content, sourced from the `wiki/**` directory that ships with @@ -17,6 +18,12 @@ import { readEnv } from '../../app-config'; const REPO = 'liketrek/TREK'; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main/wiki`; const TTL_MS = 60 * 60 * 1000; // remote fallback only: refresh from GitHub at most hourly +// Remote fallback only: raw.githubusercontent.com is a third party on the +// request path, so it gets a deadline and a size budget like every other +// outbound client. A page or a screenshot over the budget falls through to the +// stale-cache path instead of being buffered whole. +const WIKI_TIMEOUT_MS = 8000; +const WIKI_MAX_BYTES = 2 * 1024 * 1024; const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; /** @@ -78,9 +85,11 @@ async function fetchText(file: string): Promise { try { const res = await fetch(`${RAW_BASE}/${encodeURIComponent(file)}`, { headers: { 'User-Agent': 'TREK-help', Accept: 'text/plain' }, + signal: AbortSignal.timeout(WIKI_TIMEOUT_MS), }); - if (res.ok) { - const text = await res.text(); + if (res.ok && !exceedsDeclaredLength(res, WIKI_MAX_BYTES)) { + const { text, truncated } = await readCappedText(res, WIKI_MAX_BYTES); + if (truncated) throw new Error('wiki page exceeds size limit'); textCache.set(file, { data: text, ts: Date.now() }); return text; } @@ -245,9 +254,11 @@ export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; ty try { const res = await fetch(`${RAW_BASE}/${assetPath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'TREK-help' }, + signal: AbortSignal.timeout(WIKI_TIMEOUT_MS), }); - if (res.ok) { - const buf = Buffer.from(await res.arrayBuffer()); + if (res.ok && !exceedsDeclaredLength(res, WIKI_MAX_BYTES)) { + const { bytes: buf, truncated } = await readCapped(res, WIKI_MAX_BYTES); + if (truncated) throw new Error('wiki asset exceeds size limit'); assetCache.set(assetPath, { buf, type, ts: Date.now() }); return { buf, type }; } diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 10362a3a9..36f656020 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -9,6 +9,7 @@ import type { } from '@trek/shared'; import { readEnv, getAppUrl } from '../../app-config'; import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard'; +import { exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch'; import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys'; // ── Photo cache (disk-backed) ──────────────────────────────────────────────── import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; @@ -336,6 +337,11 @@ interface GooglePlaceDetails extends GooglePlaceResult { // Wikimedia is normally well under a second, but a cold TLS handshake from a // fresh container has been seen at eight. Enrichment answers a live dialog, so // a slow provider is dropped rather than waited out. +// A Google Maps place page is a few hundred KB; the coordinates sit in the +// embedded map data near the top, so two megabytes is plenty and keeps an +// unbounded body out of memory. +const MAX_MAPS_PAGE_BYTES = 2_000_000; + const WIKI_TIMEOUT_MS = 6000; // Tighter than the wiki calls, because this one sits at the FRONT of a chain: @@ -2043,14 +2049,24 @@ export class MapsService { // Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the // page body once and parse the coordinates out of the embedded map data. - if (!coords) { + // Only Google's own pages get read; the resolved host is what counts, so a + // short link that lands on maps.google.com still qualifies. + let resolvedHost = ''; + try { resolvedHost = new URL(resolvedUrl).hostname; } catch { /* keep the empty host, the branch is skipped */ } + if (!coords && GOOGLE_MAPS_HOSTS.includes(resolvedHost)) { try { const pageRes = await followRedirects(resolvedUrl, { headers: { 'User-Agent': UA }, }); - coords = extractCoords(await pageRes.text()); + if (!exceedsDeclaredLength(pageRes, MAX_MAPS_PAGE_BYTES)) { + // The map data sits near the top of the document, so a truncated read + // still finds the coordinates; an oversized page degrades to the same + // 400 an unparseable one already produced. + const { text } = await readCappedText(pageRes, MAX_MAPS_PAGE_BYTES); + coords = extractCoords(text); + } } catch (err) { - if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it + if ((err as { status?: number })?.status === 403) throw err; // SSRF block, surface it // Otherwise fall through to the not-found error below. } } diff --git a/server/src/nest/notifications/transports/ntfy.service.ts b/server/src/nest/notifications/transports/ntfy.service.ts index 95255259b..33905595f 100644 --- a/server/src/nest/notifications/transports/ntfy.service.ts +++ b/server/src/nest/notifications/transports/ntfy.service.ts @@ -51,7 +51,11 @@ export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null { function encodeHeaderValue(value: string): string { for (let i = 0; i < value.length; i++) { - if (value.charCodeAt(i) > 0x7f) { + const code = value.charCodeAt(i); + // Control characters go through the same RFC 2047 wrapper as non-ASCII: a + // trip title with a stray newline in it would otherwise be rejected by + // undici as an invalid header value and drop the notification silently. + if (code > 0x7f || code < 0x20 || code === 0x7f) { return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; } } diff --git a/server/src/nest/unsplash/unsplash.service.ts b/server/src/nest/unsplash/unsplash.service.ts index f3426c1eb..d4864e520 100644 --- a/server/src/nest/unsplash/unsplash.service.ts +++ b/server/src/nest/unsplash/unsplash.service.ts @@ -2,6 +2,7 @@ import { Readable } from 'node:stream'; import { v4 as uuidv4 } from 'uuid'; import { Injectable } from '@nestjs/common'; import { safeFetch } from '../../utils/ssrfGuard'; +import { exceedsDeclaredLength, readCapped } from '../../utils/cappedFetch'; import { resolveApiKey } from '../settings/instance-api-keys'; import { DatabaseService } from '../database/database.service'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; @@ -31,6 +32,9 @@ export interface UnsplashPhoto { const UNSPLASH_IMAGE_HOST = 'images.unsplash.com'; const MAX_COVER_BYTES = 15 * 1024 * 1024; +// Unsplash is a third party on the request path; safeFetch adds no deadline of +// its own, so both the search and the cover download carry one. +const UNSPLASH_TIMEOUT_MS = 10_000; const COVER_EXT_BY_TYPE: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', @@ -86,8 +90,10 @@ export class UnsplashService { Authorization: `Client-ID ${accessKey}`, 'Accept-Version': 'v1', }, + signal: AbortSignal.timeout(UNSPLASH_TIMEOUT_MS), }) : await fetch(`https://unsplash.com/napi/search/photos?${params.toString()}`, { + signal: AbortSignal.timeout(UNSPLASH_TIMEOUT_MS), headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0', Accept: '*/*', @@ -144,13 +150,16 @@ export class UnsplashService { */ async saveUnsplashCover(url: string): Promise { if (!this.isUnsplashCoverUrl(url)) throw new Error('Not an Unsplash image URL'); - const res = await safeFetch(url); + const res = await safeFetch(url, { signal: AbortSignal.timeout(UNSPLASH_TIMEOUT_MS) }); if (!res.ok) throw new Error(`Unsplash image download failed (HTTP ${res.status})`); const type = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); const ext = COVER_EXT_BY_TYPE[type]; if (!ext) throw new Error(`Unsupported cover image type: ${type || 'unknown'}`); - const buf = Buffer.from(await res.arrayBuffer()); - if (buf.byteLength > MAX_COVER_BYTES) throw new Error('Cover image too large'); + // Reject on the declared length and again while streaming: buffering the whole + // body first put the 15MB limit behind the allocation it was meant to prevent. + if (exceedsDeclaredLength(res, MAX_COVER_BYTES)) throw new Error('Cover image too large'); + const { bytes: buf, truncated } = await readCapped(res, MAX_COVER_BYTES); + if (truncated) throw new Error('Cover image too large'); const filename = `${uuidv4()}${ext}`; await this.storage.put('covers', filename, Readable.from(buf)); return filename; diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index 5619e8e6b..9d6f8f26d 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService } from '../database/database.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { exceedsDeclaredLength } from '../../utils/cappedFetch'; // --------------------------------------------------------------------------- // Types @@ -97,6 +98,13 @@ export interface VacayShare { const CACHE_TTL = 24 * 60 * 60 * 1000; const FETCH_TIMEOUT_MS = 10_000; +// A country's holidays for one year are a few kilobytes; two megabytes means the +// provider is misbehaving and we would rather report the usual error than buffer it. +const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024; +// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything +// else has no business reaching the URL path. +const YEAR_RE = /^\d{4}$/; +const COUNTRY_RE = /^[A-Za-z]{2}$/; // --------------------------------------------------------------------------- // Color palette for auto-assign @@ -455,8 +463,9 @@ export class VacayService { const cached = this.holidayCache.get(cacheKey); let holidays = cached && Date.now() - cached.time < CACHE_TTL ? cached.data as Holiday[] : undefined; if (!holidays) { + if (!COUNTRY_RE.test(country)) continue; const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); - if (!resp.ok) continue; + if (!resp.ok || exceedsDeclaredLength(resp, MAX_HOLIDAY_BYTES)) continue; holidays = await resp.json() as Holiday[]; this.holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); } @@ -1222,7 +1231,7 @@ export class VacayService { if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; try { const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); - if (!resp.ok) return { error: 'Failed to fetch countries' }; + if (!resp.ok || exceedsDeclaredLength(resp, MAX_HOLIDAY_BYTES)) return { error: 'Failed to fetch countries' }; const data = await resp.json(); this.holidayCache.set(cacheKey, { data, time: Date.now() }); return { data }; @@ -1232,12 +1241,15 @@ export class VacayService { } async getHolidays(year: string, country: string): Promise<{ data?: unknown; error?: string }> { + // Both segments land in the URL path, so they are checked before the cache + // lookup rather than after it. + if (!YEAR_RE.test(year) || !COUNTRY_RE.test(country)) return { error: 'Failed to fetch holidays' }; const cacheKey = `${year}-${country}`; const cached = this.holidayCache.get(cacheKey); if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; try { const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); - if (!resp.ok) return { error: 'Failed to fetch holidays' }; + if (!resp.ok || exceedsDeclaredLength(resp, MAX_HOLIDAY_BYTES)) return { error: 'Failed to fetch holidays' }; const data = await resp.json(); this.holidayCache.set(cacheKey, { data, time: Date.now() }); return { data }; @@ -1247,6 +1259,7 @@ export class VacayService { } async getSchoolHolidayRegions(country: string, language = 'EN'): Promise<{ data?: unknown; error?: string }> { + if (!COUNTRY_RE.test(country)) return { error: 'Failed to fetch school holiday regions' }; const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase(); const cacheKey = `school-regions-${country}-${normalizedLanguage}`; const cached = this.holidayCache.get(cacheKey); @@ -1257,6 +1270,9 @@ export class VacayService { fetch(`https://openholidaysapi.org/Subdivisions?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }), ]); if (!groupsResp.ok || !subdivisionsResp.ok) return { error: 'Failed to fetch school holiday regions' }; + if (exceedsDeclaredLength(groupsResp, MAX_HOLIDAY_BYTES) || exceedsDeclaredLength(subdivisionsResp, MAX_HOLIDAY_BYTES)) { + return { error: 'Failed to fetch school holiday regions' }; + } const data = { groups: await groupsResp.json(), subdivisions: await subdivisionsResp.json(), @@ -1269,6 +1285,7 @@ export class VacayService { } async getSchoolHolidays(year: string, country: string, subdivision?: string | null, language = 'EN', group?: string | null): Promise<{ data?: unknown; error?: string }> { + if (!YEAR_RE.test(year) || !COUNTRY_RE.test(country)) return { error: 'Failed to fetch school holidays' }; const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase(); const normalizedSubdivision = subdivision || ''; const normalizedGroup = group || ''; @@ -1288,7 +1305,7 @@ export class VacayService { headers: { accept: 'text/json' }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); - if (!resp.ok) return { error: 'Failed to fetch school holidays' }; + if (!resp.ok || exceedsDeclaredLength(resp, MAX_HOLIDAY_BYTES)) return { error: 'Failed to fetch school holidays' }; const data = await resp.json(); this.holidayCache.set(cacheKey, { data, time: Date.now() }); return { data }; diff --git a/server/src/nest/weather/weather.impl.ts b/server/src/nest/weather/weather.impl.ts index 6499dd3a9..c9a98f57d 100644 --- a/server/src/nest/weather/weather.impl.ts +++ b/server/src/nest/weather/weather.impl.ts @@ -1,3 +1,4 @@ +import { exceedsDeclaredLength } from '../../utils/cappedFetch'; // Open-Meteo sits on the request path as a third party: without a deadline a hung // connection holds the caller until the socket gives up on its own. Every other @@ -19,6 +20,34 @@ function hourIndexFromTime(time?: string): number | null { return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null; } +// A day of hourly series is a few kilobytes; a megabyte means the provider is +// misbehaving, not that the trip got longer. +const MAX_WEATHER_BYTES = 1024 * 1024; + +/** Open-Meteo GET with the shared deadline and the declared-size cap. */ +async function fetchOpenMeteo(url: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) }); + if (exceedsDeclaredLength(response, MAX_WEATHER_BYTES)) { + throw new ApiError(502, 'Open-Meteo API error'); + } + return response; +} + +/** + * lat/lng arrive as raw query strings and get interpolated straight into the + * Open-Meteo URL. The MCP tool already coerces them with z.number(); the REST + * route has no contract, so the boundary check lives here and covers both. + */ +function coord(value: string, max: number, label: string): number { + // Number('') and Number(' ') are 0, which would silently geolocate the Gulf + // of Guinea instead of failing, so an empty value is rejected outright. + const n = String(value ?? '').trim() === '' ? NaN : Number(value); + if (!Number.isFinite(n) || Math.abs(n) > max) { + throw new ApiError(400, `Invalid ${label}`); + } + return n; +} + // ── Interfaces ────────────────────────────────────────────────────────── export interface WeatherResult { @@ -212,7 +241,7 @@ async function _getWeatherImpl( // Forecast range (-1 .. +16 days) if (diffDays >= -1 && diffDays <= 16) { const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`; - const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) }); + const response = await fetchOpenMeteo(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { @@ -250,7 +279,7 @@ async function _getWeatherImpl( ? '&hourly=temperature_2m,weathercode' : ''; const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum${hourParam}&timezone=auto`; - const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) }); + const response = await fetchOpenMeteo(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { @@ -307,7 +336,7 @@ async function _getWeatherImpl( const endStr = endDate.toISOString().slice(0, 10); const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`; - const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) }); + const response = await fetchOpenMeteo(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { @@ -360,7 +389,7 @@ async function _getWeatherImpl( if (cached) return cached; const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,weathercode&timezone=auto`; - const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) }); + const response = await fetchOpenMeteo(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { @@ -382,13 +411,15 @@ async function _getWeatherImpl( } export async function getWeather( - lat: string, - lng: string, + rawLat: string, + rawLng: string, date: string | undefined, lang: string, /** HH:MM of the moment being asked about. Only the archive path can use it. */ time?: string, ): Promise { + const lat = String(coord(rawLat, 90, 'latitude')); + const lng = String(coord(rawLng, 180, 'longitude')); const ck = cacheKey(lat, lng, date ? `${date}T${time ?? ''}` : date); const cached = getCached(ck); if (cached) return cached; @@ -433,7 +464,7 @@ async function _getDetailedWeatherImpl( + `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m` + `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset` + `&timezone=auto`; - const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) }); + const response = await fetchOpenMeteo(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { @@ -496,7 +527,7 @@ async function _getDetailedWeatherImpl( + `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max` + `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`; - const response = await fetch(url, { signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS) }); + const response = await fetchOpenMeteo(url); const data = await response.json() as OpenMeteoForecast; if (!response.ok || data.error) { @@ -555,11 +586,13 @@ async function _getDetailedWeatherImpl( } export async function getDetailedWeather( - lat: string, - lng: string, + rawLat: string, + rawLng: string, date: string, lang: string, ): Promise { + const lat = String(coord(rawLat, 90, 'latitude')); + const lng = String(coord(rawLng, 180, 'longitude')); const ck = `detailed_${cacheKey(lat, lng, date)}`; const cached = getCached(ck); if (cached) return cached; diff --git a/server/src/utils/cappedFetch.ts b/server/src/utils/cappedFetch.ts new file mode 100644 index 000000000..89ff4653c --- /dev/null +++ b/server/src/utils/cappedFetch.ts @@ -0,0 +1,78 @@ +/** + * Response size caps for outbound fetches. + * + * Every outbound request has to bound what it buffers, and there are two + * things worth bounding: the length the provider declares (cheap, but a + * chunked response omits it) and the bytes that actually arrive. The + * declared-length check is the pattern transit.service.ts established; the + * streaming read is the one the plugin installer needed for codeload + * archives, lifted here so the other fetch sites can share it. + */ + +/** Minimal shape of the bits of Response these helpers touch (test stubs are partial). */ +interface CappedReader { + read(): Promise<{ done?: boolean; value?: Uint8Array }>; + cancel(): Promise; +} +interface CappedResponse { + headers?: { get(name: string): string | null } | null; + body?: { getReader(): CappedReader } | null; + arrayBuffer?(): Promise; + text?(): Promise; +} + +/** True when the provider declares a body larger than the budget. */ +export function exceedsDeclaredLength(res: CappedResponse, maxBytes: number): boolean { + return Number(res.headers?.get('content-length') ?? 0) > maxBytes; +} + +/** + * Read a body, stopping once maxBytes have arrived. Callers that must reject + * an oversized payload look at `truncated`; callers that only scan the head of + * the document (og: tags, a coordinate regex) can use the partial bytes and + * degrade to whatever they find. Responses without a stream fall back to + * arrayBuffer(), which is post-checked instead. + */ +export async function readCapped(res: CappedResponse, maxBytes: number): Promise<{ bytes: Buffer; truncated: boolean }> { + const reader = res.body?.getReader(); + if (!reader) { + const raw = res.arrayBuffer ? Buffer.from(await res.arrayBuffer()) : Buffer.from(res.text ? await res.text() : ''); + return raw.length > maxBytes + ? { bytes: raw.subarray(0, maxBytes), truncated: true } + : { bytes: raw, truncated: false }; + } + + const chunks: Buffer[] = []; + let received = 0; + let truncated = false; + for (;;) { + const { done, value } = await reader.read(); + if (done || !value) break; + chunks.push(Buffer.from(value)); + received += value.length; + if (received > maxBytes) { + truncated = true; + await reader.cancel().catch(() => {}); + break; + } + } + const bytes = Buffer.concat(chunks); + return { bytes: truncated ? bytes.subarray(0, maxBytes) : bytes, truncated }; +} + +/** + * readCapped for text bodies. Decodes as UTF-8, so a chopped trailing sequence + * comes back as a replacement character rather than as a throw. Without a + * stream it reads text() directly instead of going through readCapped, which + * prefers arrayBuffer(). + */ +export async function readCappedText(res: CappedResponse, maxBytes: number): Promise<{ text: string; truncated: boolean }> { + if (!res.body?.getReader && res.text) { + const raw = Buffer.from(await res.text(), 'utf8'); + return raw.length > maxBytes + ? { text: new TextDecoder('utf-8').decode(raw.subarray(0, maxBytes)), truncated: true } + : { text: raw.toString('utf8'), truncated: false }; + } + const { bytes, truncated } = await readCapped(res, maxBytes); + return { text: new TextDecoder('utf-8').decode(bytes), truncated }; +} diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index fe84dd6d6..631f6a08f 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -395,6 +395,31 @@ describe('getGithubReleases', () => { expect(result).toHaveLength(2); expect((result as any[])[0].tag_name).toBe('v3.0.0'); }); + + it('ADMIN-SVC-053a — clamps the paging query instead of interpolating it raw', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => '[]' }); + vi.stubGlobal('fetch', fetchMock); + + await getGithubReleases('9999', '0'); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.github.com/repos/liketrek/TREK/releases?per_page=100&page=1', + ); + + await getGithubReleases('10&per_page=999', 'abc'); + expect(fetchMock.mock.calls[1][0]).toBe( + 'https://api.github.com/repos/liketrek/TREK/releases?per_page=10&page=1', + ); + }); + + it('ADMIN-SVC-053b — round-trips the paging the admin UI actually sends', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => '[]' }); + vi.stubGlobal('fetch', fetchMock); + + await getGithubReleases('20', '2'); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.github.com/repos/liketrek/TREK/releases?per_page=20&page=2', + ); + }); }); // ── checkVersion ────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index fecd1ab6c..474a2324f 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -616,6 +616,36 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => { expect(result.lng).toBeCloseTo(-74.0445, 3); }); + it('MAPS-CID-003: does not read the page body of a non-Google resolved URL', async () => { + // The body branch used to run for any host, turning resolve-url into an + // authenticated outbound GET primitive. + const fetchMock = vi.fn(async (u: string) => { + if (u.includes('nominatim')) { + return { ok: true, json: async () => ({ display_name: 'x', name: null, address: {} }) }; + } + if (u.includes('goo.gl')) return { url: 'https://internal.example.com/status' }; + return { url: 'https://internal.example.com/status', text: async () => 'x!3d40.6892!4d-74.0445y' }; + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(svc.resolveGoogleMapsUrl('https://goo.gl/maps/abc123')).rejects.toMatchObject({ status: 400 }); + // Only the redirect-following call went out; the page body was never fetched. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('MAPS-CID-004: skips a page body that declares more than the size cap', async () => { + const fetchMock = vi.fn(async (u: string) => { + if (u.includes('cid=')) return { url: 'https://www.google.com/maps/place/Somewhere' }; + return { + url: 'https://www.google.com/maps/place/Somewhere', + headers: { get: (h: string) => (h === 'content-length' ? String(50_000_000) : null) }, + text: async () => 'x!3d40.6892!4d-74.0445y', + }; + }); + vi.stubGlobal('fetch', fetchMock); + await expect(svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999')).rejects.toMatchObject({ status: 400 }); + }); + it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms of CPU', () => { const adversarial = '/@' + '1'.repeat(10000) + '.'; expect(cpuMillis(() => { adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); })).toBeLessThan(500); diff --git a/server/tests/unit/nest/notification-transports.test.ts b/server/tests/unit/nest/notification-transports.test.ts index 501782542..ca08abb14 100644 --- a/server/tests/unit/nest/notification-transports.test.ts +++ b/server/tests/unit/nest/notification-transports.test.ts @@ -486,6 +486,33 @@ describe('sendNtfy', () => { expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header }); + it('NTFY-009a — a title carrying CR/LF is RFC 2047 encoded instead of reaching the header raw', async () => { + // undici rejects a header value with a control character, so a trip titled + // with a stray newline used to drop the notification for every recipient. + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + const result = await sendNtfy(ntfyUrl, null, { ...payload, title: 'Trip\r\nX-Injected: 1' }); + + expect(result).toBe(true); + const [, calledOpts] = mockFetch.mock.calls[0]; + const encoded = calledOpts.headers['Title'] as string; + expect(encoded).toMatch(/^=\?UTF-8\?B\?/); + expect(encoded).not.toContain('\n'); + const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, ''); + expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Trip\r\nX-Injected: 1'); + }); + + it('NTFY-009b — a plain ASCII title is still sent verbatim', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + await sendNtfy(ntfyUrl, null, { ...payload, title: 'Rome 2026 - day 3' }); + + const [, calledOpts] = mockFetch.mock.calls[0]; + expect(calledOpts.headers['Title']).toBe('Rome 2026 - day 3'); + }); + it('NTFY-009 — title with non-Latin-1 chars is RFC 2047 encoded', async () => { const mockFetch = globalThis.fetch as unknown as ReturnType; mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); diff --git a/server/tests/unit/nest/unsplash/unsplash.service.test.ts b/server/tests/unit/nest/unsplash/unsplash.service.test.ts index ed6a15fb7..f75b6e511 100644 --- a/server/tests/unit/nest/unsplash/unsplash.service.test.ts +++ b/server/tests/unit/nest/unsplash/unsplash.service.test.ts @@ -78,6 +78,14 @@ describe('unsplashService.searchUnsplashPhotos', () => { expect(await searchUnsplashPhotos(' ')).toEqual({ error: 'Search query is required', status: 400 }); }); + it('UNSPLASH-002a: gives the search request a deadline so a hung upstream cannot pin the caller', async () => { + const fetchMock = vi.fn().mockResolvedValue(fakeRes({ ok: true, type: 'application/json', json: { results: [] } })); + vi.stubGlobal('fetch', fetchMock); + + await searchUnsplashPhotos('paris'); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); + it('UNSPLASH-003: maps a non-ok response to an error', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes({ ok: false, status: 429, type: 'application/json', json: { errors: ['Rate limited'] } }))); expect(await searchUnsplashPhotos('paris')).toEqual({ error: 'Rate limited', status: 429 }); @@ -217,6 +225,53 @@ describe('unsplashService.saveUnsplashCover', () => { expect(writtenCovers()).toEqual([]); }); + it('UNSPLASH-008a: rejects on the declared length before the body is buffered', async () => { + const arrayBuffer = vi.fn(async () => new ArrayBuffer(8)); + safeFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: (h: string) => (h.toLowerCase() === 'content-type' ? 'image/png' : String(20 * 1024 * 1024)), + }, + arrayBuffer, + } as unknown as Response); + + await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow('Cover image too large'); + expect(arrayBuffer).not.toHaveBeenCalled(); + expect(writtenCovers()).toEqual([]); + }); + + it('UNSPLASH-008b: stops a chunked oversized body mid-stream instead of buffering it', async () => { + let cancelled = false; + let served = 0; + safeFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'image/png' : null) }, + body: { + getReader: () => ({ + read: async () => { + served++; + return { done: false, value: new Uint8Array(4 * 1024 * 1024) }; + }, + cancel: async () => { cancelled = true; }, + }), + }, + } as unknown as Response); + + await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow('Cover image too large'); + expect(cancelled).toBe(true); + // 15MB budget, 4MB chunks: the read stops on the fourth, not after an endless body. + expect(served).toBe(4); + expect(writtenCovers()).toEqual([]); + }); + + it('UNSPLASH-008c: passes a deadline through safeFetch', async () => { + safeFetch.mockResolvedValue(fakeRes({ ok: true, type: 'image/jpeg', bytes: 32 })); + await saveUnsplashCover('https://images.unsplash.com/photo-1'); + expect(safeFetch.mock.calls[0][1]).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) })); + }); + it('UNSPLASH-009: throws when the download fails', async () => { safeFetch.mockResolvedValue(fakeRes({ ok: false, status: 404 })); await expect(saveUnsplashCover('https://images.unsplash.com/photo-1')).rejects.toThrow(/HTTP 404/); diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 9b963c2f7..2c037c490 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -1518,6 +1518,41 @@ describe('quirk fixes', () => { expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) })); }); + it('VACAY-SVC-070a: getHolidays refuses a year or country that is not a plain code', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub()); + + for (const [year, country] of [['../../..', 'DE'], ['2026', 'DE/../../x'], ['20xx', 'DE'], ['2026', 'DEU']]) { + expect(await fresh.getHolidays(year, country)).toEqual({ error: 'Failed to fetch holidays' }); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('VACAY-SVC-070b: getSchoolHolidayRegions refuses a country that is not an alpha-2 code', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub()); + + expect(await fresh.getSchoolHolidayRegions('DE&countryIsoCode=FR')).toEqual({ + error: 'Failed to fetch school holiday regions', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('VACAY-SVC-070c: a provider body over the size cap reads as the usual fetch error', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: (h: string) => (h === 'content-length' ? String(50 * 1024 * 1024) : null) }, + json: async () => [{ date: '2026-01-01' }], + }); + vi.stubGlobal('fetch', fetchMock); + const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub()); + + expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' }); + expect(await fresh.getHolidays('2026', 'DE')).toEqual({ error: 'Failed to fetch holidays' }); + }); + it('VACAY-SVC-071: applyHolidayCalendars honors the cache TTL', async () => { const { plan } = setupUserWithPlan(); testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id); diff --git a/server/tests/unit/nest/weather/weather.impl.test.ts b/server/tests/unit/nest/weather/weather.impl.test.ts index eb25cc27d..f3f8eb0f7 100644 --- a/server/tests/unit/nest/weather/weather.impl.test.ts +++ b/server/tests/unit/nest/weather/weather.impl.test.ts @@ -830,3 +830,41 @@ describe('getWeather error paths', () => { expect(fetch).toHaveBeenCalledTimes(2); }); }); + +// ── coordinate boundary validation ─────────────────────────────────────────── + +describe('coordinate validation', () => { + beforeEach(() => { + vi.mocked(fetch).mockReset(); + }); + + it.each([ + ['not-a-number', '10'], + ['', '10'], + ['91', '10'], + ['10', '181'], + ['10', '&forecast_days=16&latitude=0'], + ])('WEATHER-COORD-001: getWeather rejects lat=%s lng=%s with a 400 before any fetch', async (lat, lng) => { + await expect(getWeather(lat, lng, undefined, 'en')).rejects.toMatchObject({ status: 400 }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('WEATHER-COORD-002: getDetailedWeather rejects an out-of-range latitude', async () => { + await expect(getDetailedWeather('120', '10', dateOffset(2), 'en')).rejects.toMatchObject({ status: 400 }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('WEATHER-COORD-003: a body over the declared size cap is a 502, not a parsed answer', async () => { + const date = dateOffset(5); + const body = { + daily: { time: [date], temperature_2m_max: [20], temperature_2m_min: [10], weathercode: [0] }, + }; + const oversized = { + ...mockResponse(body), + headers: { get: (h: string) => (h === 'content-length' ? String(5 * 1024 * 1024) : null) }, + } as unknown as Response; + vi.mocked(fetch).mockResolvedValue(oversized); + + await expect(getWeather('42.40', '9.41', date, 'en')).rejects.toMatchObject({ status: 502 }); + }); +}); diff --git a/server/tests/unit/nest/wiki.test.ts b/server/tests/unit/nest/wiki.test.ts index 99b212130..bd16f0aa2 100644 --- a/server/tests/unit/nest/wiki.test.ts +++ b/server/tests/unit/nest/wiki.test.ts @@ -175,6 +175,27 @@ describe('wiki — GitHub fallback when no wiki is on disk', () => { expect(after.title).toBe(before.title); }); + it('passes a deadline to every GitHub request', async () => { + const wiki = await loadWiki(MISSING_WIKI); + fetchSpy.mockResolvedValue(ok('# Timed')); + + await wiki.getWikiPage('Home'); + + expect(fetchSpy.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); + + it('falls through to WikiNotFound when GitHub declares a body over the cap', async () => { + const wiki = await loadWiki(MISSING_WIKI); + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: (h: string) => (h === 'content-length' ? String(5 * 1024 * 1024) : null) }, + text: async () => '# Huge', + } as unknown as Response); + + await expect(wiki.getWikiPage('Home')).rejects.toBeInstanceOf(wiki.WikiNotFound); + }); + it('404s when GitHub is unreachable and nothing is cached', async () => { const wiki = await loadWiki(MISSING_WIKI); fetchSpy.mockRejectedValue(new Error('network down')); diff --git a/server/tests/unit/utils/cappedFetch.test.ts b/server/tests/unit/utils/cappedFetch.test.ts new file mode 100644 index 000000000..e6a2c77ec --- /dev/null +++ b/server/tests/unit/utils/cappedFetch.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { exceedsDeclaredLength, readCapped, readCappedText } from '../../../src/utils/cappedFetch'; + +/** Response stub that streams `chunks` through a reader, tracking cancellation. */ +function streamed(chunks: string[]) { + let i = 0; + const state = { cancelled: false }; + return { + state, + res: { + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => + i < chunks.length + ? { done: false, value: new TextEncoder().encode(chunks[i++]) } + : { done: true, value: undefined as unknown as Uint8Array }, + cancel: async () => { state.cancelled = true; }, + }), + }, + }, + }; +} + +describe('exceedsDeclaredLength', () => { + it('CAP-001: is true when content-length is over the budget', () => { + const res = { headers: { get: () => '2048' } }; + expect(exceedsDeclaredLength(res, 1024)).toBe(true); + }); + + it('CAP-002: is false when content-length is missing or under the budget', () => { + expect(exceedsDeclaredLength({ headers: { get: () => '512' } }, 1024)).toBe(false); + expect(exceedsDeclaredLength({ headers: { get: () => null } }, 1024)).toBe(false); + expect(exceedsDeclaredLength({}, 1024)).toBe(false); + }); +}); + +describe('readCapped', () => { + it('CAP-003: returns the whole body when it fits', async () => { + const { res, state } = streamed(['hello ', 'world']); + const { bytes, truncated } = await readCapped(res, 1024); + expect(bytes.toString('utf8')).toBe('hello world'); + expect(truncated).toBe(false); + expect(state.cancelled).toBe(false); + }); + + it('CAP-004: stops at the budget and cancels the reader when the body is oversized', async () => { + const { res, state } = streamed(['aaaa', 'bbbb', 'cccc']); + const { bytes, truncated } = await readCapped(res, 6); + expect(bytes.length).toBe(6); + expect(bytes.toString('utf8')).toBe('aaaabb'); + expect(truncated).toBe(true); + expect(state.cancelled).toBe(true); + }); + + it('CAP-005: survives a reader whose cancel rejects', async () => { + const { res } = streamed(['aaaa', 'bbbb']); + res.body.getReader = ((orig) => () => ({ ...orig(), cancel: async () => { throw new Error('nope'); } }))( + res.body.getReader, + ); + const { truncated } = await readCapped(res, 2); + expect(truncated).toBe(true); + }); + + it('CAP-006: falls back to arrayBuffer when the response exposes no stream', async () => { + const res = { arrayBuffer: async () => new TextEncoder().encode('body').buffer }; + const { bytes, truncated } = await readCapped(res, 1024); + expect(bytes.toString('utf8')).toBe('body'); + expect(truncated).toBe(false); + }); + + it('CAP-007: truncates the arrayBuffer fallback too', async () => { + const res = { arrayBuffer: async () => new TextEncoder().encode('abcdef').buffer }; + const { bytes, truncated } = await readCapped(res, 3); + expect(bytes.toString('utf8')).toBe('abc'); + expect(truncated).toBe(true); + }); + + it('CAP-008: falls back to text() when there is neither a stream nor arrayBuffer', async () => { + const { bytes } = await readCapped({ text: async () => 'from text' }, 1024); + expect(bytes.toString('utf8')).toBe('from text'); + }); + + it('CAP-009: yields an empty body for a stub with no reader at all', async () => { + const { bytes, truncated } = await readCapped({}, 1024); + expect(bytes.length).toBe(0); + expect(truncated).toBe(false); + }); +}); + +describe('readCappedText', () => { + it('CAP-010: decodes the collected bytes as UTF-8', async () => { + const { res } = streamed(['', 'Hallo Grüße', '']); + const { text, truncated } = await readCappedText(res, 1024); + expect(text).toBe('Hallo Grüße'); + expect(truncated).toBe(false); + }); + + it('CAP-011a: prefers text() over arrayBuffer() when there is no stream', async () => { + const res = { + text: async () => '# markdown', + arrayBuffer: async () => new ArrayBuffer(4), + }; + const { text } = await readCappedText(res, 1024); + expect(text).toBe('# markdown'); + }); + + it('CAP-011b: truncates the text() fallback at the budget', async () => { + const { text, truncated } = await readCappedText({ text: async () => 'abcdef' }, 3); + expect(text).toBe('abc'); + expect(truncated).toBe(true); + }); + + it('CAP-011: reports truncation and still returns the head', async () => { + const { res } = streamed(['x', 'z'.repeat(100)]); + const { text, truncated } = await readCappedText(res, 20); + expect(truncated).toBe(true); + expect(text.startsWith('x')).toBe(true); + expect(text.length).toBe(20); + }); +}); From 515b832fa3da292a4fbb4b5882093ae3d2b7d661 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:47:29 +0200 Subject: [PATCH 06/25] fix(collab): stop serving the text of deleted chat messages A deleted message keeps its row so the client can draw the placeholder off the flag, but listMessages still returned the original text - in the list itself and in the reply preview joined onto whatever quoted it. Both REST and the MCP resource read through that one method, so blanking it there covers every surface. Also cap the link-preview read, and delete the spooled upload when addNoteFile refuses the request instead of leaving it on disk. --- server/src/nest/collab/collab.controller.ts | 20 ++++++- server/src/nest/collab/collab.service.ts | 19 +++++- .../tests/unit/nest/collab.controller.test.ts | 28 ++++++++- server/tests/unit/nest/collab.service.test.ts | 58 +++++++++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/server/src/nest/collab/collab.controller.ts b/server/src/nest/collab/collab.controller.ts index a8a54bb7e..4f35faf4b 100644 --- a/server/src/nest/collab/collab.controller.ts +++ b/server/src/nest/collab/collab.controller.ts @@ -17,6 +17,7 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import type { Options } from 'multer'; import path from 'path'; +import fs from 'fs'; import type { User } from '../../types'; import { CollabService } from './collab.service'; import { StorageService } from '../storage/storage.service'; @@ -146,9 +147,20 @@ export class CollabController { @Post('notes/:id/files') @UseInterceptors(FileInterceptor('file')) async addNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) { - const trip = this.requireTrip(tripId, user); - if (!this.collab.canUploadFiles(trip, user)) { - throw new HttpException({ error: 'No permission to upload files' }, 403); + // multer has already written the upload to the spool dir by the time any of + // these checks run, and nothing sweeps orphans, so every refusal takes the + // bytes back out, with the same closure the trip-file upload uses. + const cleanupSpool = () => { + if (file?.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } + }; + try { + const trip = this.requireTrip(tripId, user); + if (!this.collab.canUploadFiles(trip, user)) { + throw new HttpException({ error: 'No permission to upload files' }, 403); + } + } catch (err) { + cleanupSpool(); + throw err; } if (!file) { throw new HttpException({ error: 'No file uploaded' }, 400); @@ -158,6 +170,8 @@ export class CollabController { await this.storage.put('files', file.filename, { tmpPath: file.path }); const result = this.collab.addNoteFile(tripId, id, file); if (!result) { + // Already committed, so the spool file is gone; drop the final object. + await this.storage.delete('files', file.filename).catch(() => {}); throw new HttpException({ error: 'Note not found' }, 404); } this.collab.broadcast(tripId, 'collab:note:updated', { note: this.collab.getFormattedNoteById(tripId, id) }, socketId); diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index efd1b2d1b..173b5cdab 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -6,12 +6,17 @@ import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { avatarUrl } from '../common/avatarUrl'; import { checkSsrf, createPinnedDispatcher } from '../../utils/ssrfGuard'; +import { exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch'; import type { CollabNote, CollabPoll, CollabMessage, TripFile, User } from '../../types'; import { NotificationsService } from '../notifications/notifications.service'; import { StorageService } from '../storage/storage.service'; type Trip = TripAccess; +// Half a megabyte of markup is far more than any og:/ block needs, and +// the URL is caller-supplied, so read no further than that. +const MAX_PREVIEW_BYTES = 512 * 1024; + export interface ReactionRow { emoji: string; user_id: number; @@ -374,7 +379,8 @@ export class CollabService { listMessages(tripId: string | number, before?: string | number) { const query = ` SELECT m.*, u.username, u.avatar, - rm.text AS reply_text, ru.username AS reply_username + CASE WHEN rm.deleted = 1 THEN '' ELSE rm.text END AS reply_text, + ru.username AS reply_username FROM collab_messages m JOIN users u ON m.user_id = u.id LEFT JOIN collab_messages rm ON m.reply_to = rm.id @@ -390,6 +396,12 @@ export class CollabService { messages.reverse(); + // A deleted message keeps its row so the client can render the placeholder + // off the flag, but the original text must not leave the server. REST and + // both MCP surfaces read through this method, so blanking here covers all + // three. + for (const m of messages) if (m.deleted) m.text = ''; + const msgIds = messages.map(m => m.id); const reactionsByMsg: Record<number, ReactionRow[]> = {}; if (msgIds.length > 0) { @@ -468,8 +480,11 @@ export class CollabService { } as any); clearTimeout(timeout); if (!r.ok) throw new Error('Fetch failed'); + if (exceedsDeclaredLength(r, MAX_PREVIEW_BYTES)) return fallback; - const html = await r.text(); + // A truncated head still carries the tags we scrape, so a page over the + // budget degrades to fewer fields rather than to an error. + const { text: html } = await readCappedText(r, MAX_PREVIEW_BYTES); const get = (prop: string) => { const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i')) || html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i')); diff --git a/server/tests/unit/nest/collab.controller.test.ts b/server/tests/unit/nest/collab.controller.test.ts index 29292f8ac..08da31f29 100644 --- a/server/tests/unit/nest/collab.controller.test.ts +++ b/server/tests/unit/nest/collab.controller.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HttpException } from '@nestjs/common'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { CollabController } from '../../../src/nest/collab/collab.controller'; import type { CollabService } from '../../../src/nest/collab/collab.service'; @@ -19,7 +22,10 @@ function svc(o: Partial<CollabService> = {}): CollabService { } as unknown as CollabService; } -const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService; +const storageStub = { + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), +} as unknown as StorageService; function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { @@ -89,6 +95,26 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock'); }); + it('reclaims the upload on every refusal, spooled or already committed', async () => { + // multer has written the file before any check runs, and nothing sweeps + // orphans, so a loop of rejected POSTs would otherwise fill the disk. + const spool = path.join(os.tmpdir(), `trek-collab-${Date.now()}-${Math.random().toString(16).slice(2)}.pdf`); + fs.writeFileSync(spool, 'x'); + const spooled = { filename: 'a.pdf', path: spool } as Express.Multer.File; + expect(await thrownAsync(() => new CollabController(svc({ canUploadFiles: vi.fn().mockReturnValue(false) }), storageStub).addNoteFile(user, '5', '9', spooled))).toEqual({ status: 403, body: { error: 'No permission to upload files' } }); + expect(fs.existsSync(spool)).toBe(false); + + const spool2 = path.join(os.tmpdir(), `trek-collab-${Date.now()}-${Math.random().toString(16).slice(2)}.pdf`); + fs.writeFileSync(spool2, 'x'); + expect(await thrownAsync(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockReturnValue(null) }), storageStub).addNoteFile(user, '5', '9', { filename: 'b.pdf', path: spool2 } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(fs.existsSync(spool2)).toBe(false); + + // Past the commit the spool file is gone, so the final object is what has + // to go instead. + await thrownAsync(() => new CollabController(svc({ addNoteFile: vi.fn().mockReturnValue(null) } as Partial<CollabService>), storageStub).addNoteFile(user, '5', '9', file)); + expect(storageStub.delete).toHaveBeenCalledWith('files', 'a.pdf'); + }); + it('DELETE file 404 when missing, else success', async () => { expect(await thrownAsync(() => new CollabController(svc({ deleteNoteFile: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).deleteNoteFile(user, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } }); const s = svc({ deleteNoteFile: vi.fn().mockResolvedValue(true), getFormattedNoteById: vi.fn().mockReturnValue({ id: 9 }), broadcast: vi.fn() } as Partial<CollabService>); diff --git a/server/tests/unit/nest/collab.service.test.ts b/server/tests/unit/nest/collab.service.test.ts index d85d84854..651561ea3 100644 --- a/server/tests/unit/nest/collab.service.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -209,6 +209,28 @@ describe('listMessages', () => { expect(msgs[2].text).toBe('C'); }); + it('COLLAB-SVC-013a: blanks the text of a deleted message but keeps the flag', () => { + const { user1, trip } = setup(); + const r = svc.createMessage(trip.id, user1.id, 'Secret plans'); + svc.deleteMessage(trip.id, r.message!.id, user1.id); + + const msgs = svc.listMessages(trip.id); + expect(msgs).toHaveLength(1); + expect(msgs[0].text).toBe(''); + expect(msgs[0].deleted).toBe(1); + }); + + it('COLLAB-SVC-013b: blanks reply_text when the quoted message was deleted', () => { + const { user1, trip } = setup(); + const original = svc.createMessage(trip.id, user1.id, 'Original secret'); + svc.createMessage(trip.id, user1.id, 'Quoting it', original.message!.id); + svc.deleteMessage(trip.id, original.message!.id, user1.id); + + const msgs = svc.listMessages(trip.id); + const reply = msgs.find(m => m.text === 'Quoting it')!; + expect(reply.reply_text).toBe(''); + }); + it('COLLAB-SVC-013: includes reactions grouped by emoji', () => { const { user1, trip } = setup(); const r = svc.createMessage(trip.id, user1.id, 'React me'); @@ -398,6 +420,42 @@ describe('linkPreview', () => { expect(result.url).toBe('https://example.com/net-error'); }); + it('COLLAB-SVC-030a: returns the fallback when the page declares a body over the cap', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + headers: { get: (h: string) => (h === 'content-length' ? String(10 * 1024 * 1024) : null) }, + text: async () => '<html><head><title>Huge', + })); + + const result = await svc.linkPreview('https://example.com/huge'); + expect(result.title).toBeNull(); + }); + + it('COLLAB-SVC-030b: reads only up to the cap and still scrapes the head tags', async () => { + let cancelled = false; + const chunks = [ + new TextEncoder().encode('Head Title'), + new TextEncoder().encode('x'.repeat(1024 * 1024)), + ]; + let i = 0; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => (i < chunks.length ? { done: false, value: chunks[i++] } : { done: true }), + cancel: async () => { cancelled = true; }, + }), + }, + })); + + const result = await svc.linkPreview('https://example.com/streamed'); + // The head arrives first, so the scrape still works; the megabyte of padding + // past the 512KB budget is dropped and the reader is cancelled. + expect(result.title).toBe('Head Title'); + expect(cancelled).toBe(true); + }); + it('COLLAB-SVC-030: falls back to meta description tag when no og:description', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, From 4bfb27742345d0cbbc2154879db1e67661770992 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:47:42 +0200 Subject: [PATCH 07/25] fix(journey,files): close the cross-trip and role holes on the sharing surface Files: two :id handlers took the file id from the URL and never resolved it against :tripId. Membership of any trip was enough to read another trip's file links (with the joined reservation titles) or to delete one. Both now resolve the file the way every sibling handler already does. Journey: a contributor with role 'viewer' - blocked from every other write in the domain - could overwrite or delete the Studio book, and could read the share token, which is an anonymous credential that outlives their contributor status. The book writes take canEdit now and the token read takes the owner check its own create and delete already use. Also: the share-link MCP tool reset every flag to permissive defaults when it was called to refresh a link, so a journey deliberately shared without its gallery or map quietly started publishing both; setShareLink dropped newest_first entirely; the public routes ignored the Journey addon switch; and photo uploads committed to storage before the access check, leaking the objects on the 403 path. Trips: DELETE answered 403 to someone with no access at all, which turns sequential ids into an existence oracle. A member who simply lacks trip_delete still gets 403. --- server/src/nest/files/files.constants.ts | 6 +- server/src/nest/files/files.controller.ts | 11 ++++ .../src/nest/journey/journey-book.service.ts | 22 +++++-- .../nest/journey/journey-public.controller.ts | 18 +++++- .../src/nest/journey/journey-share.service.ts | 32 +++++++--- server/src/nest/journey/journey.controller.ts | 27 ++++++++- server/src/nest/journey/journey.mcp.ts | 20 +++++-- server/src/nest/journey/journey.service.ts | 8 +-- server/src/nest/trips/trips.controller.ts | 7 +++ server/tests/e2e/journey.e2e.test.ts | 9 +++ server/tests/integration/journey.test.ts | 48 ++++++++++++++- server/tests/integration/trips.test.ts | 8 ++- server/tests/unit/db/trip-access.test.ts | 32 ++++++++++ server/tests/unit/mcp/tools-journey.test.ts | 40 +++++++++++++ .../tests/unit/nest/files.controller.test.ts | 14 ++++- server/tests/unit/nest/files.service.test.ts | 6 +- .../unit/nest/journey-book.service.test.ts | 16 ++++- .../unit/nest/journey-share.service.test.ts | 50 ++++++++++++++++ .../unit/nest/journey.controller.test.ts | 59 +++++++++++++++++-- .../tests/unit/nest/trips.controller.test.ts | 14 +++++ 20 files changed, 403 insertions(+), 44 deletions(-) diff --git a/server/src/nest/files/files.constants.ts b/server/src/nest/files/files.constants.ts index e3144ce1b..ea336244b 100644 --- a/server/src/nest/files/files.constants.ts +++ b/server/src/nest/files/files.constants.ts @@ -31,8 +31,10 @@ export function isVideoExtension(ext: string): boolean { // `allowed_file_types` is `*`, this list is still enforced so the // wildcard doesn't silently admit executables/scripts. export const BLOCKED_EXTENSIONS = [ - // Server-rendered / scripted content that could XSS a viewer - '.svg', '.html', '.htm', '.xml', '.xhtml', + // Server-rendered / scripted content that could XSS a viewer. Downloads are + // served inline with an extension-derived Content-Type, so every spelling a + // browser renders as a document has to be listed, not just the common ones. + '.svg', '.svgz', '.html', '.htm', '.shtml', '.shtm', '.xml', '.xhtml', '.xht', // Scripts '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.php', '.py', '.rb', '.pl', // Executables diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index 34f2c0767..246c4b953 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -279,6 +279,13 @@ export class FilesController { if (!this.files.can('file_edit', trip, user)) { throw new HttpException({ error: 'No permission' }, 403); } + // deleteFileLink scopes by (linkId, fileId) only, so the file itself has to + // be resolved against :tripId first. Otherwise a member of any trip could + // drop a link row belonging to a foreign trip's file. + const file = this.files.getFileById(id, tripId); + if (!file) { + throw new HttpException({ error: 'File not found' }, 404); + } this.files.deleteFileLink(linkId, id); return { success: true }; } @@ -286,6 +293,10 @@ export class FilesController { @UseGuards(TripAccessGuard) @Get(':id/links') links(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string) { + const file = this.files.getFileById(id, tripId); + if (!file) { + throw new HttpException({ error: 'File not found' }, 404); + } return { links: this.files.getFileLinks(id) }; } } diff --git a/server/src/nest/journey/journey-book.service.ts b/server/src/nest/journey/journey-book.service.ts index 57c2b5da6..9df274c64 100644 --- a/server/src/nest/journey/journey-book.service.ts +++ b/server/src/nest/journey/journey-book.service.ts @@ -9,10 +9,11 @@ import { JourneyDomainService } from './journey-domain.service'; * * ── Access ─────────────────────────────────────────────────────────────── * - * A book belongs to its journey, so it inherits the journey's access exactly — - * every contributor can open and edit it. There is no separate book permission - * and there should not be: a second access model over the same object is how - * two rules end up disagreeing about who may do what. + * A book belongs to its journey, so it inherits the journey's access exactly: + * every contributor can open it, and the ones who may edit the journey may edit + * it. There is no separate book permission and there should not be: a second + * access model over the same object is how two rules end up disagreeing about + * who may do what. * * ── Concurrency ────────────────────────────────────────────────────────── * @@ -37,6 +38,15 @@ export class JourneyBookService { return !!this.journey.canAccessJourney(journeyId, userId); } + /** + * Writing the book is an edit like any other in this domain, so it takes the + * same owner-or-editor check the entry and photo writes take. canAccess also + * covers role 'viewer', who may read the book but must not overwrite it. + */ + private canWrite(journeyId: number, userId: number): boolean { + return this.journey.canEdit(journeyId, userId); + } + /** * Whether this user may open the journey's book at all. * @@ -115,7 +125,7 @@ export class JourneyBookService { userId: number, input: { title: string; document: unknown; baseVersion?: number }, ): { record: BookRecord } | { conflict: BookRecord } | null { - if (!this.canAccess(journeyId, userId)) return null; + if (!this.canWrite(journeyId, userId)) return null; const document = JSON.stringify(normalizeBookDocument(input.document)); const existing = this.db @@ -160,7 +170,7 @@ export class JourneyBookService { } deleteBook(journeyId: number, userId: number): boolean | null { - if (!this.canAccess(journeyId, userId)) return null; + if (!this.canWrite(journeyId, userId)) return null; const result = this.db .prepare('DELETE FROM journey_books WHERE journey_id = ?') .run(journeyId); diff --git a/server/src/nest/journey/journey-public.controller.ts b/server/src/nest/journey/journey-public.controller.ts index fff8c467f..a52fe23af 100644 --- a/server/src/nest/journey/journey-public.controller.ts +++ b/server/src/nest/journey/journey-public.controller.ts @@ -1,8 +1,11 @@ -import { Controller, Get, HttpException, Param, Res } from '@nestjs/common'; +import { Controller, Get, HttpException, Param, Res, UseGuards } from '@nestjs/common'; import type { Response } from 'express'; import path from 'node:path'; import { JourneyService } from './journey.service'; import { StorageService } from '../storage/storage.service'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { ADDON_IDS } from '../../addons'; import { Public } from '../auth/public.decorator'; /** @@ -10,11 +13,18 @@ import { Public } from '../auth/public.decorator'; * proxy for publicly shared journeys. * * Byte-identical to the legacy Express route (server/src/routes/journeyPublic.ts): - * NOT behind any guard, every route validates the share token (404 on failure), + * no authentication, every route validates the share token (404 on failure), * the unified proxy streams by trek_photo_id and the legacy proxy serves local * files (with the uploads-dir traversal guard) or proxies immich/synology. + * + * The addon gate is the one guard here, and it has to be: turning the Journey + * addon off is meant to take the feature away, and without it already-published + * journeys stayed publicly readable while the authenticated surface went dark. + * AddonGuard only reads addon state, so it is safe in front of a @Public route. */ @Public('share-token validated: the whole point is a link that works without an account') +@UseGuards(AddonGuard) +@RequireAddon(ADDON_IDS.JOURNEY, 'Journey') @Controller('api/public/journey') export class JourneyPublicController { constructor( @@ -72,7 +82,9 @@ export class JourneyPublicController { return; } - const effectiveOwnerId = valid.ownerId || Number(ownerId); + // :ownerId stays in the path so old share links keep routing, but it is not + // read any more: the owner comes from the row the token resolved to. + const effectiveOwnerId = valid.ownerId; const streaming = this.journey.streamProviderAsset( res, provider, diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts index 56a7ec1c9..53ad8ccf0 100644 --- a/server/src/nest/journey/journey-share.service.ts +++ b/server/src/nest/journey/journey-share.service.ts @@ -44,6 +44,22 @@ export class JourneyShareService { // able to publish the journey or change which screens are shared. if (!this.journey.isOwner(journeyId, createdBy)) return null; + const existing = this.db.prepare('SELECT token, share_timeline, share_gallery, share_map, newest_first FROM journey_share_tokens WHERE journey_id = ?') + .get(journeyId) as { token: string; share_timeline: number; share_gallery: number; share_map: number; newest_first: number } | undefined; + + if (existing) { + // An update only changes the flags it was actually given. Falling back to + // the create-time defaults here would silently re-publish a gallery or map + // the owner had switched off, at the unchanged token. + const share_timeline = permissions.share_timeline ?? !!existing.share_timeline; + const share_gallery = permissions.share_gallery ?? !!existing.share_gallery; + const share_map = permissions.share_map ?? !!existing.share_map; + const newest_first = permissions.newest_first ?? !!existing.newest_first; + this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ?, newest_first = ? WHERE journey_id = ?') + .run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0, journeyId); + return { token: existing.token, created: false }; + } + const { share_timeline = true, share_gallery = true, @@ -51,13 +67,6 @@ export class JourneyShareService { newest_first = false, } = permissions; - const existing = this.db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined; - if (existing) { - this.db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ?, newest_first = ? WHERE journey_id = ?') - .run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0, journeyId); - return { token: existing.token, created: false }; - } - const token = crypto.randomBytes(24).toString('base64url'); this.db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map, newest_first) VALUES (?, ?, ?, ?, ?, ?, ?)') .run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, newest_first ? 1 : 0); @@ -109,13 +118,18 @@ export class JourneyShareService { // owner shared the gallery. if (!row.share_gallery) return null; const photo = this.db.prepare(` - SELECT tkp.owner_id FROM journey_photos gp + SELECT tkp.owner_id, j.user_id AS journey_owner_id + FROM journey_photos gp JOIN trek_photos tkp ON tkp.id = gp.photo_id + JOIN journeys j ON j.id = gp.journey_id WHERE tkp.asset_id = ? AND gp.journey_id = ? `).get(assetId, row.journey_id) as any; // Only resolve assets that actually belong to this shared journey. if (!photo) return null; - return { ownerId: photo.owner_id }; + // trek_photos.owner_id can be NULL. The journey's owner is the fallback, the + // same one the photo proxy uses. Whose provider credentials get tried must + // never come from a number an anonymous caller put in the URL. + return { ownerId: photo.owner_id || photo.journey_owner_id }; } getPublicJourney(token: string) { diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 2f00865ca..a1a9f7c54 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -152,6 +152,20 @@ export class JourneyController { } } + /** + * Drop uploads that were committed before the authorization result was known. + * + * The commit has to happen first (the Immich mirror reads the final path), so + * every refusal after it has to take the bytes back out. Nothing sweeps + * orphans, and a loop of rejected POSTs would otherwise just fill the disk. + */ + private async discardJourneyUploads(files: Express.Multer.File[]): Promise { + for (const f of files) { + if (f.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } } + await this.storage.delete('journey', f.filename).catch(() => {}); + } + } + // The add call carries only an asset id, so when and where the picture was taken // are fetched from the provider afterwards rather than trusted from the client // (#1614). Detached: a slow or unreachable provider must not hold up the add. @@ -220,10 +234,15 @@ export class JourneyController { // reads each file at its final uploads/journey path. await this.commitJourneyUploads(files); const results: unknown[] = []; + const refused: Express.Multer.File[] = []; for (const file of files) { const relativePath = `journey/${file.filename}`; const photo = this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption as string | undefined); - if (!photo) continue; + if (!photo) { + // No row points at this file, so nothing would ever clean it up. + refused.push(file); + continue; + } // Mirror to Immich only when the user explicitly opted in (#730). if (this.journey.immichAutoUploadEnabled(user.id)) { try { @@ -238,6 +257,9 @@ export class JourneyController { } results.push(photo); } + if (refused.length) { + await this.discardJourneyUploads(refused); + } if (!results.length) { throw new HttpException({ error: 'Not allowed' }, 403); } @@ -348,6 +370,7 @@ export class JourneyController { const filePaths = files.map((f) => ({ path: `journey/${f.filename}` })); const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths); if (!photos.length) { + await this.discardJourneyUploads(files); throw new HttpException({ error: 'Not allowed' }, 403); } // An uploaded file carries its own EXIF; reading it is what puts the photo on @@ -465,6 +488,7 @@ export class JourneyController { await this.commitJourneyUploads([file]); const result = this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` }); if (!result) { + await this.discardJourneyUploads([file]); throw new HttpException({ error: 'Journey not found' }, 404); } return result; @@ -679,6 +703,7 @@ export class JourneyController { share_timeline: body.share_timeline as boolean | undefined, share_gallery: body.share_gallery as boolean | undefined, share_map: body.share_map as boolean | undefined, + newest_first: body.newest_first as boolean | undefined, }); if (!result) { throw new HttpException({ error: 'Not allowed' }, 403); diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 69e69eeec..fcb7d3470 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -408,28 +408,36 @@ export class JourneyMcp { @Tool({ name: 'get_journey_share_link', - description: 'Get the current public share link for a journey. Returns null if none exists.', + description: 'Get the current public share link for a journey. Owner only. Returns null if none exists.', inputSchema: { journeyId: z.number().int().positive() }, annotations: TOOL_ANNOTATIONS_READONLY, when: journeyAddonOn, access: { group: 'journey', mode: 'share' }, }) getJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) { - if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); + // Owner only, same as the REST route: handing out the token is handing out + // the journey. + if (!this.journey.isOwner(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); return ok({ shareLink: this.share.getJourneyShareLink(journeyId) }); } @Tool({ name: 'create_journey_share_link', - description: 'Create or update the public share link for a journey. Owner only.', - inputSchema: { journeyId: z.number().int().positive() }, + description: 'Create or update the public share link for a journey. Owner only. Flags left out keep their current value on an existing link; a new link defaults to timeline/gallery/map on.', + inputSchema: { + journeyId: z.number().int().positive(), + share_timeline: z.boolean().optional(), + share_gallery: z.boolean().optional(), + share_map: z.boolean().optional(), + newest_first: z.boolean().optional(), + }, annotations: TOOL_ANNOTATIONS_WRITE, when: journeyAddonOn, access: { group: 'journey', mode: 'share' }, }) - createJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) { + createJourneyShareLink({ journeyId, ...permissions }: { journeyId: number; share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; newest_first?: boolean }, ctx: McpContext) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const shareLink = this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, {}); + const shareLink = this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, permissions); if (!shareLink) return notFound('Journey not found or access denied.'); return ok({ shareLink }); } diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index bd21a24f7..ff13a8a8a 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -78,11 +78,11 @@ export class JourneyService { removeContributor(id: number, userId: number, targetUserId: number) { return this.journey.removeContributor(id, userId, targetUserId); } // Share links - // Authorization: only someone with access to the journey may read its public - // share token — same access model as create/delete here and the - // get_journey_share_link MCP tool. + // Authorization: the token is the whole credential, so reading it needs the + // same owner check create/delete use. A viewer who reads it keeps a working + // anonymous link after being removed as a contributor. getJourneyShareLink(id: number, userId: number) { - if (!this.journey.canAccessJourney(id, userId)) return null; + if (!this.journey.isOwner(id, userId)) return null; return this.share.getJourneyShareLink(id); } createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters[2]) { return this.share.createOrUpdateJourneyShareLink(id, userId, data); } diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 36b6bd7c4..43197a89f 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -239,6 +239,13 @@ export class TripsController { if (!owner) { throw new HttpException({ error: 'Trip not found' }, 404); } + // Someone with no access at all gets the same 404 as a trip that does not + // exist, otherwise the 403 below turns sequential ids into an existence + // oracle. Admins are exempt: they may delete trips they are not a member of, + // which is what the isAdminDelete branch further down relies on. + if (user.role !== 'admin' && !this.trips.canAccessTrip(id, user.id)) { + throw new HttpException({ error: 'Trip not found' }, 404); + } if (!this.trips.can('trip_delete', user.role, owner.user_id, user.id, owner.user_id !== user.id)) { throw new HttpException({ error: 'No permission to delete this trip' }, 403); } diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index dacb7a6f0..a77f52927 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -317,6 +317,15 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ id: 9 }); }); + it('public journey read still answers the addon gate when Journey is off', async () => { + // The authenticated surface goes dark with the addon; a published journey + // has to go with it, or turning the switch off does not take the feature away. + isAddonEnabled.mockReturnValue(false); + const res = await request(server).get('/api/public/journey/tok'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Journey addon is not enabled' }); + }); + it('the image fileFilter is wired: a disallowed extension is rejected, not stored', async () => { // The multer options are built by MulterModule.registerAsync now, and Nest // injects MULTER_MODULE_OPTIONS with @Optional(): a token that fails to diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts index 421b07751..0f0f1f7f1 100644 --- a/server/tests/integration/journey.test.ts +++ b/server/tests/integration/journey.test.ts @@ -417,6 +417,48 @@ describe('Journey share link', () => { expect(get.body.link.share_map).toBe(false); }); + it('JOURNEY-INT-044 — a contributor cannot read the token, only the owner can', async () => { + const { user: owner } = createUser(testDb); + const { user: helper } = createUser(testDb); + const journey = createJourney(testDb, owner.id); + addJourneyContributor(testDb, journey.id, helper.id, 'editor'); + await request(app) + .post(`/api/journeys/${journey.id}/share-link`) + .set('Cookie', authCookie(owner.id)) + .send({ share_timeline: true, share_gallery: true, share_map: true }); + + // The token is the whole credential and it outlives the contributor's + // access, so reading it takes the same owner check create and delete take. + const res = await request(app) + .get(`/api/journeys/${journey.id}/share-link`) + .set('Cookie', authCookie(helper.id)); + + expect(res.status).toBe(200); + expect(res.body.link).toBeNull(); + }); + + it('JOURNEY-INT-045 — a flag-only update leaves the other flags alone', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + await request(app) + .post(`/api/journeys/${journey.id}/share-link`) + .set('Cookie', authCookie(user.id)) + .send({ share_timeline: true, share_gallery: false, share_map: false, newest_first: true }); + + await request(app) + .post(`/api/journeys/${journey.id}/share-link`) + .set('Cookie', authCookie(user.id)) + .send({ share_timeline: false }); + + const get = await request(app) + .get(`/api/journeys/${journey.id}/share-link`) + .set('Cookie', authCookie(user.id)); + expect(get.body.link.share_timeline).toBe(false); + expect(get.body.link.share_gallery).toBe(false); + expect(get.body.link.share_map).toBe(false); + expect(get.body.link.newest_first).toBe(true); + }); + it('JOURNEY-INT-017 — DELETE /api/journeys/:id/share-link deletes the share link', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -1082,7 +1124,7 @@ describe('Journey upload parity', () => { expect(res.body.photos.length).toBeGreaterThan(0); }); - it('JOURNEY-P05 — non-contributor gallery upload is 403 and files remain on disk (no cleanup today)', async () => { + it('JOURNEY-P05 — non-contributor gallery upload is 403 and the bytes are taken back off disk', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -1094,8 +1136,10 @@ describe('Journey upload parity', () => { .attach('photos', FIXTURE_IMG); expect(res.status).toBe(403); expect(res.body.error).toBe('Not allowed'); + // The commit runs before the permission answer is known, so the refusal has + // to delete the object again. Nothing sweeps orphans. const after = fsMod.existsSync(journeyDir) ? fsMod.readdirSync(journeyDir).length : 0; - expect(after).toBe(before + 1); + expect(after).toBe(before); }); it('JOURNEY-P06 — video+poster upload: poster is ALWAYS stored as .jpg (stored-XSS pin)', async () => { diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index b878a1eab..8a78b573c 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -527,7 +527,7 @@ describe('Delete trip', () => { expect(getRes.status).toBe(404); }); - it('TRIP-019 — Regular user cannot delete another users trip → 403', async () => { + it('TRIP-019 — Regular user cannot delete another users trip → 404', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" }); @@ -536,8 +536,10 @@ describe('Delete trip', () => { .delete(`/api/trips/${trip.id}`) .set('Cookie', authCookie(other.id)); - // getTripOwner finds the trip (it exists); checkPermission fails for non-members → 403 - expect(res.status).toBe(403); + // 404, not 403: someone with no access at all must not be able to tell an + // existing trip from a missing one by walking sequential ids. A member who + // simply lacks trip_delete still gets 403 — see the case below. + expect(res.status).toBe(404); // Trip still exists const tripInDb = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id); diff --git a/server/tests/unit/db/trip-access.test.ts b/server/tests/unit/db/trip-access.test.ts index a24cfc2e0..6a4567e90 100644 --- a/server/tests/unit/db/trip-access.test.ts +++ b/server/tests/unit/db/trip-access.test.ts @@ -7,6 +7,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; import { db, canAccessTrip } from '../../../src/db/database'; +import { CAN_ACCESS_TRIP_SQL, buildDbMock, createTestDb } from '../../helpers/test-db'; function seedUser(username: string): number { return Number( @@ -52,3 +53,34 @@ describe('canAccessTrip', () => { expect(canAccessTrip(tripId, stranger)).toBeUndefined(); }); }); + +/** + * The mock every domain test runs against had the pre-#1543 SELECT, so the very + * regression the block above pins was invisible to the suites that mock the db. + */ +describe('the buildDbMock stand-in for canAccessTrip', () => { + it('hands back the trip currency, like the real one', () => { + const testDb = createTestDb(); + try { + const owner = Number( + testDb.prepare("INSERT INTO users (username, email, password_hash, role) VALUES ('m', 'm@example.test', 'x', 'user')") + .run().lastInsertRowid, + ); + const tripId = Number( + testDb.prepare("INSERT INTO trips (user_id, title, currency) VALUES (?, 'Trip', 'ISK')") + .run(owner).lastInsertRowid, + ); + + expect(buildDbMock(testDb).canAccessTrip(tripId, owner)).toMatchObject({ id: tripId, currency: 'ISK' }); + } finally { + testDb.close(); + } + }); + + it('selects the same columns the production statement does', () => { + const columns = (sql: string) => sql.replace(/\s+/g, ' ').trim(); + expect(columns(CAN_ACCESS_TRIP_SQL)).toBe( + 'SELECT t.id, t.user_id, t.currency 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)', + ); + }); +}); diff --git a/server/tests/unit/mcp/tools-journey.test.ts b/server/tests/unit/mcp/tools-journey.test.ts index 06327ffff..eb913c2f7 100644 --- a/server/tests/unit/mcp/tools-journey.test.ts +++ b/server/tests/unit/mcp/tools-journey.test.ts @@ -441,6 +441,46 @@ describe('journey share-link tools', () => { }); }); + it('passes the share flags through, and an omitted flag keeps its value', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const journey = await seedJourney(h); + await h.client.callTool({ + name: 'create_journey_share_link', + arguments: { journeyId: journey.id, share_gallery: false, newest_first: true }, + }); + // Calling it again without the flags must not re-publish the gallery. + await h.client.callTool({ name: 'create_journey_share_link', arguments: { journeyId: journey.id } }); + const read = parseToolResult(await h.client.callTool({ + name: 'get_journey_share_link', arguments: { journeyId: journey.id }, + })) as any; + expect(read.shareLink.share_gallery).toBe(false); + expect(read.shareLink.newest_first).toBe(true); + expect(read.shareLink.share_timeline).toBe(true); + }); + }); + + it('get_journey_share_link is owner-only, like create and delete', async () => { + const { user: owner } = createUser(testDb); + const { user: guest } = createUser(testDb); + let journeyId = 0; + await withHarness(owner.id, async (h) => { + const journey = await seedJourney(h); + journeyId = journey.id; + await h.client.callTool({ name: 'create_journey_share_link', arguments: { journeyId } }); + }); + testDb.prepare( + 'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)', + ).run(journeyId, guest.id, 'viewer', Date.now()); + await withHarness(guest.id, async (h) => { + // The token is the whole credential: a contributor must not be able to + // read out a link that keeps working after they are removed. + expect((await h.client.callTool({ + name: 'get_journey_share_link', arguments: { journeyId }, + })).isError).toBe(true); + }); + }); + it('every share tool refuses a foreign journey', async () => { const { user } = createUser(testDb); const { journeyId } = foreignJourney(); diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index 426d8418c..2a3006180 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -171,12 +171,22 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route it('DELETE /:id/link/:linkId removes the link; GET /:id/links lists', () => { const deleteFileLink = vi.fn(); - expect(fc(fsvc({ deleteFileLink } as Partial)).unlink(user, trip, '5', '9', '3')).toEqual({ success: true }); + expect(fc(fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), deleteFileLink } as Partial)).unlink(user, trip, '5', '9', '3')).toEqual({ success: true }); expect(deleteFileLink).toHaveBeenCalledWith('3', '9'); - const s = fsvc({ getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); + const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); expect(fc(s).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] }); }); + it('the link routes resolve the file against :tripId, so a foreign file is 404', () => { + const foreign = () => fsvc({ getFileById: vi.fn().mockReturnValue(undefined), deleteFileLink: vi.fn(), getFileLinks: vi.fn() } as Partial); + const unlinkSvc = foreign(); + expect(thrown(() => fc(unlinkSvc).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } }); + expect(unlinkSvc.deleteFileLink).not.toHaveBeenCalled(); + const listSvc = foreign(); + expect(thrown(() => fc(listSvc).links(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); + expect(listSvc.getFileLinks).not.toHaveBeenCalled(); + }); + it('the trash + link routes all reject without file_delete / file_edit', async () => { const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) }); await expect(fc(denied()).permanent(user, trip, '5', '9')).rejects.toMatchObject({ status: 403 }); diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index 69fdeb9b1..6fbcc027d 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -141,10 +141,14 @@ describe('files.constants', () => { }); it('FILE-SVC-003: blocklist, size caps and the default allowlist are pinned', () => { - expect(BLOCKED_EXTENSIONS).toHaveLength(25); + expect(BLOCKED_EXTENSIONS).toHaveLength(29); expect(BLOCKED_EXTENSIONS).toContain('.svg'); expect(BLOCKED_EXTENSIONS).toContain('.exe'); expect(BLOCKED_EXTENSIONS).toContain('.ps1'); + // The alternative spellings a browser still renders as a document. + for (const ext of ['.svgz', '.shtml', '.shtm', '.xht']) { + expect(BLOCKED_EXTENSIONS).toContain(ext); + } expect(MAX_FILE_SIZE).toBe(50 * 1024 * 1024); expect(MAX_VIDEO_SIZE).toBe(500 * 1024 * 1024); expect(DEFAULT_ALLOWED_EXTENSIONS).toBe('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass,pkpasses,md,markdown'); diff --git a/server/tests/unit/nest/journey-book.service.test.ts b/server/tests/unit/nest/journey-book.service.test.ts index 6c34dbce8..9fe725dec 100644 --- a/server/tests/unit/nest/journey-book.service.test.ts +++ b/server/tests/unit/nest/journey-book.service.test.ts @@ -1,7 +1,7 @@ /** * Storing TREK Studio books (#1973). * - * The half worth testing hard is concurrency. Everyone on a journey can edit + * The half worth testing hard is concurrency. Every editor on a journey edits * the same book, so "two saves arrive together" is the ordinary case rather * than an edge one, and the failure mode of getting it wrong is somebody's * afternoon disappearing with no error anywhere. @@ -103,6 +103,20 @@ describe('access', () => { expect(books.getBook(journey.id, helper.id)).not.toBeNull(); }); + it('lets a viewer read the book but not overwrite or delete it', () => { + const { user: owner } = createUser(testDb); + const { user: guest } = createUser(testDb); + const journey = createJourney(testDb, owner.id); + addJourneyContributor(testDb, journey.id, guest.id, 'viewer'); + books.saveBook(journey.id, owner.id, { title: 'Iceland', document: doc() }); + + expect(books.canOpen(journey.id, guest.id)).toBe(true); + expect(books.getBook(journey.id, guest.id)).not.toBeNull(); + expect(books.saveBook(journey.id, guest.id, { title: 'mine now', document: doc('two') })).toBeNull(); + expect(books.deleteBook(journey.id, guest.id)).toBeNull(); + expect(books.getBook(journey.id, owner.id)!.title).toBe('Iceland'); + }); + it('says nothing about a journey that does not exist', () => { const { user } = createUser(testDb); expect(books.canOpen(999_999, user.id)).toBe(false); diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts index 005f946ef..6b5fee09e 100644 --- a/server/tests/unit/nest/journey-share.service.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -148,6 +148,41 @@ describe('createOrUpdateJourneyShareLink', () => { expect(link!.share_map).toBe(false); }); + it('JOURNEY-SHARE-027: an update leaves out flags it was not given', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + + svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + share_timeline: true, + share_gallery: false, + share_map: false, + newest_first: true, + }); + // A caller that only flips the timeline must not silently re-publish the + // gallery and map at the unchanged token. + svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false }); + + const link = svc.getJourneyShareLink(journey.id); + expect(link!.share_timeline).toBe(false); + expect(link!.share_gallery).toBe(false); + expect(link!.share_map).toBe(false); + expect(link!.newest_first).toBe(true); + }); + + it('JOURNEY-SHARE-028: newest_first survives a flag-only update and is settable', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + + svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(false); + + svc.createOrUpdateJourneyShareLink(journey.id, user.id, { newest_first: true }); + expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(true); + + svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false }); + expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(true); + }); + it('JOURNEY-SHARE-004: different journeys get different tokens', () => { const { user } = createUser(testDb); const j1 = createJourney(testDb, user.id); @@ -323,6 +358,21 @@ describe('validateShareTokenForAsset', () => { expect(svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull(); }); + it('JOURNEY-SHARE-029: falls back to the journey owner when the photo has no owner_id', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + insertJourneyPhoto(entry.id, { assetId: 'immich-asset-orphan' }); + const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + + // Without the fallback the controller filled the gap from the :ownerId path + // segment, i.e. an anonymous caller picked whose provider credentials to try. + const result = svc.validateShareTokenForAsset(token, 'immich-asset-orphan'); + + expect(result).not.toBeNull(); + expect(result!.ownerId).toBe(user.id); + }); + it('JOURNEY-SHARE-015: denies (returns null) when the asset is not part of the shared journey', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); diff --git a/server/tests/unit/nest/journey.controller.test.ts b/server/tests/unit/nest/journey.controller.test.ts index 47e120170..3e8022711 100644 --- a/server/tests/unit/nest/journey.controller.test.ts +++ b/server/tests/unit/nest/journey.controller.test.ts @@ -6,6 +6,9 @@ import { JourneyController } from '../../../src/nest/journey/journey.controller' import { journeyThumbName } from '../../../src/nest/memories/thumbnail.service'; import type { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service'; import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller'; +import { AddonGuard } from '../../../src/nest/addons/addon.guard'; +import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'; +import { ADDON_IDS } from '../../../src/addons'; import type { JourneyService } from '../../../src/nest/journey/journey.service'; import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service'; import type { StorageService } from '../../../src/nest/storage/storage.service'; @@ -186,10 +189,48 @@ describe('JourneyController', () => { expect(thrown(() => ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } }); expect(ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } }); expect(thrown(() => ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } }); - expect(ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' }); + const setLink = vi.fn().mockReturnValue({ token: 'abc' }); + expect(ctl(svc({ createOrUpdateJourneyShareLink: setLink } as Partial)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' }); + // newest_first is part of the shared contract and the public viewer reads + // it, so it has to reach the service instead of being dropped here (#1614). + expect(setLink).toHaveBeenCalledWith(9, 1, { share_timeline: true, share_gallery: undefined, share_map: undefined, newest_first: undefined }); + const withOrder = vi.fn().mockReturnValue({ token: 'abc' }); + ctl(svc({ createOrUpdateJourneyShareLink: withOrder } as Partial)).setShareLink(user, '9', { newest_first: true }); + expect(withOrder).toHaveBeenCalledWith(9, 1, { share_timeline: undefined, share_gallery: undefined, share_map: undefined, newest_first: true }); expect(thrown(() => ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); + it('takes the committed bytes back out when the upload is refused', async () => { + // The commit happens before the authorization result is known (the Immich + // mirror reads the final path), so every refusal after it has to delete the + // object again. Nothing sweeps orphans. + const file = { filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File; + await thrownAsync(() => ctl(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial)).uploadEntryPhotos(user, '3', [file], {})); + expect(storageStub.delete).toHaveBeenCalledWith('journey', 'a.jpg'); + + vi.clearAllMocks(); + await thrownAsync(() => ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial)).uploadGalleryPhotos(user, '3', [{ filename: 'g.jpg' } as Express.Multer.File])); + expect(storageStub.delete).toHaveBeenCalledWith('journey', 'g.jpg'); + + vi.clearAllMocks(); + await thrownAsync(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)); + expect(storageStub.delete).toHaveBeenCalledWith('journey', 'c.jpg'); + }); + + it('drops the files of a partly refused entry batch, keeping the accepted ones', async () => { + // A 200 with fewer photos than files still leaves unreferenced objects behind. + const addPhoto = vi.fn().mockReturnValueOnce({ id: 5 }).mockReturnValueOnce(null); + const immichAutoUploadEnabled = vi.fn().mockReturnValue(false); + const files = [ + { filename: 'ok.jpg', originalname: 'ok.jpg' } as Express.Multer.File, + { filename: 'no.jpg', originalname: 'no.jpg' } as Express.Multer.File, + ]; + const res = await ctl(svc({ addPhoto, immichAutoUploadEnabled } as Partial)).uploadEntryPhotos(user, '3', files, {}); + expect(res).toEqual({ photos: [{ id: 5 }] }); + expect(storageStub.delete).toHaveBeenCalledWith('journey', 'no.jpg'); + expect(storageStub.delete).not.toHaveBeenCalledWith('journey', 'ok.jpg'); + }); + it('entry photo upload mirrors to Immich only when opted in', async () => { const addPhoto = vi.fn().mockReturnValue({ id: 5 }); const uploadToImmich = vi.fn().mockResolvedValue('immich-1'); @@ -376,6 +417,13 @@ describe('JourneyController', () => { }); describe('JourneyPublicController', () => { + it('is addon-gated like the authenticated surface', () => { + // Turning the Journey addon off is meant to take the feature away; without + // this, already-published journeys stayed publicly readable. + expect(Reflect.getMetadata(REQUIRE_ADDON, JourneyPublicController)).toEqual({ addonId: ADDON_IDS.JOURNEY, label: 'Journey' }); + expect((Reflect.getMetadata('__guards__', JourneyPublicController) ?? [])[0]).toBe(AddonGuard); + }); + it('GET /:token 404 / json', () => { expect(thrown(() => new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue(null) } as Partial), storageStub).get('tok'))).toEqual({ status: 404, body: { error: 'Not found' } }); expect(new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue({ id: 1 }) } as Partial), storageStub).get('tok')).toEqual({ id: 1 }); @@ -435,11 +483,14 @@ describe('JourneyPublicController', () => { expect(json).toHaveBeenCalledWith({ error: 'Provider not supported' }); }); - it('legacy photo proxy: falls back to the path ownerId when the token has none', async () => { + it('legacy photo proxy: ignores the path ownerId, taking the owner off the token', async () => { + // Whose provider credentials get tried is not something an anonymous caller + // may choose from the URL. The share service resolves it, falling back to + // the journey owner when the photo row carries no owner_id. const streamProviderAsset = vi.fn().mockResolvedValue(undefined); - const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 0 }), streamProviderAsset } as Partial); + const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 3 }), streamProviderAsset } as Partial); await new JourneyPublicController(s, storageStub).legacyPhoto('tok', 'immich', 'a1', '8', 'original', {} as Response); - expect(streamProviderAsset).toHaveBeenCalledWith({}, 'immich', { userId: 8, ownerId: 8, assetId: 'a1' }, 'original'); + expect(streamProviderAsset).toHaveBeenCalledWith({}, 'immich', { userId: 3, ownerId: 3, assetId: 'a1' }, 'original'); }); it('legacy photo proxy: local provider 404s when the object does not exist', async () => { diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index a26a8c935..1d2acfa98 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -280,6 +280,20 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(thrown(() => tc(s).remove(user, '9', req))).toEqual({ status: 403, body: { error: 'No permission to delete this trip' } }); }); + it('404s for someone with no access, so the 403 is not an existence oracle', () => { + const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), canAccessTrip: vi.fn().mockReturnValue(null), remove: vi.fn() } as Partial); + expect(thrown(() => tc(s).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(s.remove).not.toHaveBeenCalled(); + }); + + it('still lets an admin delete a trip they are not a member of', () => { + const admin = { id: 1, role: 'admin', email: 'a@example.test' } as User; + const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: true, ownerEmail: 'owner@x.y' }); + const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), canAccessTrip: vi.fn().mockReturnValue(null), remove, broadcast: vi.fn() } as Partial); + expect(tc(s).remove(admin, '9', req)).toEqual({ success: true }); + expect(remove).toHaveBeenCalledWith('9', 1, 'admin'); + }); + it('deletes, audits and broadcasts', () => { const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: false }); const broadcast = vi.fn(); const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 1 }), remove, broadcast } as Partial); From 8e5fdaa877b1554c720cbf777b93bcff9ff636e1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:47:58 +0200 Subject: [PATCH 08/25] fix(server): tighten the guard, audit and token paths Permissions cached an empty map before it had loaded, so a DB error during the load cached permissive defaults and every later check answered from that cache - fail-open, and it never expired. The rate limiter grew its per-IP map forever; the GC the legacy version had was dropped in the fold. Idempotency did a check-then-insert, so two concurrent duplicates both ran the handler. Audit rows recorded whatever X-Forwarded-For said, which the client writes, and the request-body debug logger redacted by exact key match - it missed refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect from the client's Host header. CSP asked for unsafe-eval on top of the wasm-unsafe-eval it actually needs. The OAuth deny path redirected to an unvalidated URI, ws-token minting had no rate limit at all, and PKCE was skipped when code_verifier was absent rather than refused. An MCP session resumed with a token whose scopes had since been reduced kept the capability set it was created with, and the session cap was checked before the async registration that increments it. Plugin frame containment was lexical, so a symlink out of the directory was never noticed, and instance config accepted keys the manifest never declared. --- server/src/mcp/tools.ts | 74 +++--------------- server/src/middleware/globalMiddleware.ts | 29 ++++++- server/src/nest-mcp/types.ts | 15 ++-- server/src/nest/audit/audit.service.ts | 13 +++- server/src/nest/audit/client-ip.ts | 14 ++-- server/src/nest/auth/auth.controller.ts | 11 ++- .../nest/common/idempotency.interceptor.ts | 78 ++++++++++++++++--- server/src/nest/common/rate-limit.service.ts | 25 ++++-- .../mcp-transport/mcp-transport.service.ts | 29 +++++++ server/src/nest/oauth/oauth-api.controller.ts | 18 +++-- server/src/nest/oauth/oauth-sdk.provider.ts | 4 +- .../nest/permissions/permissions.service.ts | 10 ++- .../nest/plugins/plugin-frame.controller.ts | 17 ++++ server/src/nest/plugins/plugins.service.ts | 14 +++- server/tests/integration/mcp.test.ts | 53 +++++++++++++ server/tests/integration/oauth.test.ts | 26 ++++++- .../middleware/globalMiddleware.csp.test.ts | 49 +++++++++++- .../globalMiddleware.redaction.test.ts | 19 +++++ server/tests/unit/nest/audit.service.test.ts | 40 +++++++--- .../tests/unit/nest/auth.controller.test.ts | 23 +++++- .../unit/nest/idempotency.interceptor.test.ts | 58 +++++++++++++- .../unit/nest/mcp-transport.service.test.ts | 16 ++++ .../unit/nest/oauth-sdk.provider.test.ts | 11 ++- .../tests/unit/nest/oauth.controller.test.ts | 15 +++- .../unit/nest/permissions.service.test.ts | 21 ++++- .../unit/nest/rate-limit.service.test.ts | 76 ++++++++++++++++++ .../tests/unit/plugins/plugin-frame.test.ts | 39 ++++++++++ .../unit/plugins/plugins-service.test.ts | 14 ++++ 28 files changed, 671 insertions(+), 140 deletions(-) create mode 100644 server/tests/unit/nest/rate-limit.service.test.ts diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 62eb38673..0fc5ce0fd 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -1,69 +1,17 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import type { McpAttachOptions, McpRegistry } from '../nest-mcp'; +/** + * Attaches the MCP surface to a session's server. + * + * Every domain registers through an @McpController class the container + * discovers, so there is nothing to wire here by hand any more. What is left + * is the seam that supplies the default arguments and the null-registry + * escape hatch. Production passes the container-discovered McpRegistryService + * (injected into the transport service); the no-Nest harness hands in + * createTestRegistry()'s build. A null registry (direct callers without either) + * skips the attach. + */ export function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke']): void { - // The trip tools moved to the DI-discovered src/nest/trips/trips.mcp.ts and - // the share-link tools to src/nest/share/share.mcp.ts (@McpController, - // attached via the nest-mcp registry below — getDeprecationNotice rides the - // attach ctx). - - // The place tools moved to the DI-discovered src/nest/places/places.mcp.ts - // (@McpController, attached via the nest-mcp registry below). - - // The collection tools moved to the DI-discovered src/nest/collections/ - // collections.mcp.ts (@McpController, attached via the nest-mcp registry below). - - // The budget tools moved to the DI-discovered src/nest/budget/budget.mcp.ts - // (@McpController, attached via the nest-mcp registry below). - - // The packing tools moved to the DI-discovered src/nest/packing/packing.mcp.ts - // (@McpController, attached via the nest-mcp registry below). - - // The reservation tools moved to the DI-discovered src/nest/reservations/ - // reservations.mcp.ts (@McpController, attached via the nest-mcp registry below). - - // The day + accommodation tools moved to the DI-discovered src/nest/days/ - // days.mcp.ts (@McpController, attached via the nest-mcp registry below). - - // The assignment tools moved to the DI-discovered src/nest/assignments/ - // assignments.mcp.ts (@McpController, attached via the nest-mcp registry below). - - // The weather tools moved to the DI-discovered src/nest/weather/weather.mcp.ts - // and the airport tools to src/nest/airports/airports.mcp.ts (@McpController, - // attached via the nest-mcp registry below). - - // The notification tools moved to the DI-discovered src/nest/notifications/ - // notifications.mcp.ts (@McpController, attached via the nest-mcp registry below). - - // The atlas tools moved to the DI-discovered src/nest/atlas/atlas.mcp.ts - // (@McpController, attached via the nest-mcp registry below). - - // The collab tools moved to the DI-discovered src/nest/collab/collab.mcp.ts - // (@McpController, attached via the nest-mcp registry below). - - // The transport tools moved to the DI-discovered - // src/nest/reservations/reservations.mcp.ts — a transport is a reservation, - // same table and same service (@McpController, attached via the nest-mcp - // registry below). - - // The transit tools moved to the DI-discovered src/nest/transit/transit.mcp.ts - // (@McpController, attached via the nest-mcp registry below). - - - // The vacay tools moved to the DI-discovered src/nest/vacay/vacay.mcp.ts - // (@McpController, attached via the nest-mcp registry below). - - // The todo tools moved to the DI-discovered src/nest/todo/todo.mcp.ts - // (@McpController, attached via the nest-mcp registry below). - - // The prompts moved to the DI-discovered @McpController classes: - // packing-list to packing.mcp.ts, budget-overview to budget.mcp.ts and the - // static-token notice to auth.mcp.ts (its `if (isStaticToken)` became a - // `when` gate — the registry hands `when` the session context). - - // Decorator-registered domains (src/nest-mcp). Production passes the - // container-discovered McpRegistryService (injected into the transport - // service); the no-Nest harness hands in createTestRegistry()'s build. - // A null registry (direct callers without either) ⇒ skip. if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke }); } diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index cb7652005..aaca45557 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -30,13 +30,23 @@ export const SENSITIVE_KEYS = new Set([ 'secretaccesskey', ]); +/** + * Suffixes that make a field sensitive whatever its prefix. Matched on the end of + * the key, not anywhere in it: `accessKeyId` is an identifier and stays readable, + * while `refresh_token`, `code_verifier`, `mapbox_access_token` and the whole + * `*_api_key` family are secrets nobody needs in a debug log. + */ +const SENSITIVE_SUFFIXES = ['_token', '_key', '_secret', '_password', '_pass', '_verifier']; + /** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) from a request-log value. */ export function redact(value: unknown): unknown { if (!value || typeof value !== 'object') return value; if (Array.isArray(value)) return (value as unknown[]).map(redact); const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { - out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v); + const lower = k.toLowerCase(); + const sensitive = SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix)); + out[k] = sensitive ? '[REDACTED]' : redact(v); } return out; } @@ -155,7 +165,7 @@ export function applyGlobalMiddleware( contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"], + scriptSrc: ["'self'", "'wasm-unsafe-eval'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], imgSrc: ["'self'", "data:", "blob:", "https:"], connectSrc: [ @@ -197,11 +207,24 @@ export function applyGlobalMiddleware( referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, })); + // The instance's own hostname, when the operator configured one. The redirect + // below is a 301, so echoing a client-supplied Host header would let a stranger + // park a foreign origin in a visitor's cache. Falls back to the request Host for + // instances that never set APP_URL, which is what it always did. + const configuredHost = (() => { + const url = readEnv().app.appUrl; + try { + return url ? new URL(url).host : null; + } catch { + return null; + } + })(); + if (shouldForceHttps) { app.use((req: Request, res: Response, next: NextFunction) => { if (req.path === '/api/health') return next(); if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next(); - res.redirect(301, 'https://' + req.headers.host + req.url); + res.redirect(301, 'https://' + (configuredHost ?? req.headers.host) + req.url); }); } diff --git a/server/src/nest-mcp/types.ts b/server/src/nest-mcp/types.ts index 7125bf81c..31b6c0485 100644 --- a/server/src/nest-mcp/types.ts +++ b/server/src/nest-mcp/types.ts @@ -25,7 +25,8 @@ export interface McpContext { isStaticToken: boolean; /** * Fire-once static-token deprecation notice closure (built per session in - * src/mcp/index.ts and threaded through registerTools → registry.attach). + * src/nest/mcp-transport/mcp-transport.service.ts and threaded through + * registerTools → registry.attach). * Optional so direct createTestRegistry ctxs without it keep working — * consumers (list_trips / get_trip_summary) treat absence as "no notice". */ @@ -38,11 +39,10 @@ export interface McpContext { * a group come from the access policy in `src/mcp/nest-mcp-policy.ts`, not * from here. */ -// The registry pattern requires an empty single-extends interface — the -// keys ARE the contract, so the lint's "no members" finding is a false -// positive. -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface McpAccessGroupRegistry extends Record {} +// A plain alias, not an empty interface: the empty single-extends form only +// existed so an external host could augment the union, and since the fold into +// the server there is no such host. `keyof` resolves the same either way. +export type McpAccessGroupRegistry = Record; export type McpAccessGroup = keyof McpAccessGroupRegistry extends never ? string @@ -54,8 +54,7 @@ export type McpAccessGroup = keyof McpAccessGroupRegistry extends never * could only be expressed as opaque predicates, which the boot gate in * `src/mcp/nest-mcp-policy.ts` cannot check. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface McpAccessModeRegistry extends Record {} +export type McpAccessModeRegistry = Record; export type McpAccessMode = keyof McpAccessModeRegistry extends never ? 'read' | 'write' diff --git a/server/src/nest/audit/audit.service.ts b/server/src/nest/audit/audit.service.ts index 58ff13546..b077ae8df 100644 --- a/server/src/nest/audit/audit.service.ts +++ b/server/src/nest/audit/audit.service.ts @@ -26,6 +26,13 @@ const ACTION_LABELS: Record = { 'immich.private_ip_configured': 'configured Immich with private IP', }; +/** + * Collapses line breaks so a trip title (free user text) cannot forge a second + * log line. Scoped to the audit lines rather than the shared logger, which + * still has to print multi-line stack traces as they are. + */ +const oneLine = (s: string): string => s.replace(/[\r\n\u2028\u2029]+/g, ' '); + function buildInfoSummary(action: string, details?: Record): string { if (!details || Object.keys(details).length === 0) return ''; if (action === 'trip.create') return ` "${details.title}"`; @@ -87,12 +94,12 @@ export class AuditService { const email = this.resolveUserEmail(entry.userId); const label = ACTION_LABELS[entry.action] || entry.action; const brief = buildInfoSummary(entry.action, entry.details); - logInfo(`${email} ${label}${brief} ip=${entry.ip || '-'}`); + logInfo(oneLine(`${email} ${label}${brief} ip=${entry.ip || '-'}`)); if (entry.debugDetails && Object.keys(entry.debugDetails).length > 0) { - logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${JSON.stringify(entry.debugDetails)}`); + logDebug(oneLine(`AUDIT ${entry.action} userId=${entry.userId} ${JSON.stringify(entry.debugDetails)}`)); } else if (detailsJson) { - logDebug(`AUDIT ${entry.action} userId=${entry.userId} ${detailsJson}`); + logDebug(oneLine(`AUDIT ${entry.action} userId=${entry.userId} ${detailsJson}`)); } } catch (e) { logError(`Audit write failed: ${e instanceof Error ? e.message : e}`); diff --git a/server/src/nest/audit/client-ip.ts b/server/src/nest/audit/client-ip.ts index 8e44a77a5..7d01798b9 100644 --- a/server/src/nest/audit/client-ip.ts +++ b/server/src/nest/audit/client-ip.ts @@ -4,13 +4,13 @@ import { Request } from 'express'; * Pure request helper — no DB, no side effects — kept out of the injectable * AuditService so getClientIp-only consumers stay plain-function imports * (same carve-out precedent as files.constants.ts). + * + * It honours `trust proxy` (set in globalMiddleware from TRUST_PROXY, one hop by + * default) rather than reading X-Forwarded-For itself. The leftmost entry of + * that header is whatever the client typed, and this value ends up on the audit + * rows an operator reads after an incident — the one place a caller must not be + * able to dictate. With trust proxy off, req.ip is the socket address anyway. */ export function getClientIp(req: Request): string | null { - const xff = req.headers['x-forwarded-for']; - if (typeof xff === 'string') { - const first = xff.split(',')[0]?.trim(); - return first || null; - } - if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null; - return req.socket?.remoteAddress || null; + return req.ip?.trim() || req.socket?.remoteAddress || null; } diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index e63a732df..4b57cfa92 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -307,7 +307,13 @@ export class AuthController { @Post('ws-token') @HttpCode(200) - wsToken(@CurrentUser() user: User) { + wsToken(@CurrentUser() user: User, @Req() req: Request) { + // Own bucket, not 'login': a client that reconnects its socket in a loop + // must not be able to lock itself out of signing in. The ceiling is far + // above any real client, which mints one token per socket connect, but it + // stops a single account from filling the process-wide ephemeral store and + // 503-ing every other user's ws and download tokens. + this.limit('ws_token', req, 120); const result = this.tokens.createWsToken(user.id); if (result.error) { throw new HttpException({ error: result.error }, result.status!); @@ -317,7 +323,8 @@ export class AuthController { @Post('resource-token') @HttpCode(200) - resourceToken(@CurrentUser() user: User, @Body() body: ResourceTokenDto) { + resourceToken(@CurrentUser() user: User, @Body() body: ResourceTokenDto, @Req() req: Request) { + this.limit('resource_token', req, 120); const token = this.tokens.createResourceToken(user.id, body.purpose); if (!token) { throw new HttpException({ error: 'Service unavailable' }, 503); diff --git a/server/src/nest/common/idempotency.interceptor.ts b/server/src/nest/common/idempotency.interceptor.ts index 7908f65e3..77a9ec4ed 100644 --- a/server/src/nest/common/idempotency.interceptor.ts +++ b/server/src/nest/common/idempotency.interceptor.ts @@ -1,6 +1,7 @@ import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor } from '@nestjs/common'; import type { Request, Response } from 'express'; -import { Observable, of } from 'rxjs'; +import { Observable, from, of } from 'rxjs'; +import { finalize, switchMap } from 'rxjs/operators'; import { DatabaseService } from '../database/database.service'; /** @@ -16,8 +17,17 @@ import { DatabaseService } from '../database/database.service'; * - non-mutating method, or no key, or no authenticated user -> pass through * - key longer than the cap -> 400 with the exact legacy message * - (key, user, method, path) already stored -> replay the cached response + * - the same key still in flight -> wait for it, then replay its response * - otherwise -> capture a successful JSON response under the key * + * The in-flight step is the one thing the Express wrapper did not do. The row + * only exists once the first request answers, so two overlapping replays of one + * key (two tabs draining the same offline queue, or a client retrying after a + * timeout) both missed the SELECT and both ran the handler — the duplicate + * write the key exists to prevent. Waiting keeps the promise the client was + * given: the second caller gets the first one's response, not a new error to + * interpret. + * * Capturing wraps `res.json`, so 204 / `res.end()` responses are not cached — * matching the Express wrapper, which only fires on `res.json`. */ @@ -26,6 +36,14 @@ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const MAX_KEY_LENGTH = 128; const MAX_CACHED_BODY_BYTES = 256 * 1024; +/** + * (user, method, path, key) of every request currently running, resolved when it + * answers. In memory rather than a reservation row on purpose: better-sqlite3 is + * synchronous and the whole overlap lives inside one process, so a crash cannot + * leave a key wedged for the table's 30-day TTL. + */ +const inFlight = new Map>(); + interface IdempotencyRow { status_code: number; response_body: string; @@ -53,17 +71,51 @@ export class IdempotencyInterceptor implements NestInterceptor { throw new HttpException({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' }, 400); } - // Scope the lookup by method + path as well as user, so the same key replayed - // against a different endpoint can't return an unrelated cached body. - const existing = this.database.get( + const existing = this.lookup(key, userId, req); + if (existing) return this.replay(existing, res); + + const signature = `${userId}|${req.method}|${req.path}|${key}`; + const pending = inFlight.get(signature); + if (pending) { + return from(pending).pipe( + switchMap(() => { + const stored = this.lookup(key, userId, req); + if (stored) return this.replay(stored, res); + // The first request answered without caching anything (it failed, or + // it never went through res.json). Run this one normally rather than + // inventing a response for it. + return this.run(signature, key, userId, req, res, next); + }), + ); + } + + return this.run(signature, key, userId, req, res, next); + } + + /** + * Scope the lookup by method + path as well as user, so the same key replayed + * against a different endpoint can't return an unrelated cached body. + */ + private lookup(key: string, userId: number, req: Request): IdempotencyRow | undefined { + return this.database.get( 'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?', key, userId, req.method, req.path, ); - if (existing) { - res.status(existing.status_code); - return of(JSON.parse(existing.response_body)); - } + } + private replay(row: IdempotencyRow, res: Response): Observable { + res.status(row.status_code); + return of(JSON.parse(row.response_body)); + } + + private run( + signature: string, + key: string, + userId: number, + req: Request, + res: Response, + next: CallHandler, + ): Observable { const originalJson = res.json.bind(res); const database = this.database; res.json = function (body: unknown): Response { @@ -84,6 +136,14 @@ export class IdempotencyInterceptor implements NestInterceptor { return originalJson(body); }; - return next.handle(); + let done!: () => void; + inFlight.set(signature, new Promise((resolve) => { done = resolve; })); + + return next.handle().pipe( + finalize(() => { + inFlight.delete(signature); + done(); + }), + ); } } diff --git a/server/src/nest/common/rate-limit.service.ts b/server/src/nest/common/rate-limit.service.ts index 04239a0c8..eae580588 100644 --- a/server/src/nest/common/rate-limit.service.ts +++ b/server/src/nest/common/rate-limit.service.ts @@ -7,14 +7,19 @@ interface Attempt { count: number; first: number } * `rateLimiter`. Each named bucket keeps its own attempt map; `check` returns * false once a key exceeds `max` within `windowMs` (the caller answers 429). * - * The legacy route also ran a setInterval to garbage-collect expired records; - * that was pure memory housekeeping (the window check below already treats an - * expired record as fresh), so it is intentionally omitted — the limit - * behaviour is identical and there's no dangling timer to leak in tests. + * The legacy route ran a setInterval to garbage-collect expired records. There + * is no timer here — a dangling one leaks in tests — but the housekeeping is + * back as a lazy sweep driven by the `now` the caller already passes: keys are + * per-IP and proxy-header derived, so without it a bucket grows for every + * address that ever knocked and never shrinks again. An expired record is + * treated as fresh by the window check below, so dropping it changes nothing + * about who gets a 429. */ @Injectable() export class RateLimitService { private readonly buckets = new Map>(); + /** Last sweep per bucket, so a busy bucket doesn't keep a quiet one from being cleaned. */ + private readonly lastSweep = new Map(); private store(bucket: string): Map { let s = this.buckets.get(bucket); @@ -25,6 +30,7 @@ export class RateLimitService { /** Returns true when the request is allowed, false when it should be rejected (429). */ check(bucket: string, key: string, max: number, windowMs: number, now: number): boolean { const store = this.store(bucket); + this.sweep(bucket, store, windowMs, now); const record = store.get(key); if (record && record.count >= max && now - record.first < windowMs) { return false; @@ -37,9 +43,18 @@ export class RateLimitService { return true; } + /** Drops records whose window has elapsed, at most once per window per bucket. */ + private sweep(bucket: string, store: Map, windowMs: number, now: number): void { + if (now - (this.lastSweep.get(bucket) ?? 0) < windowMs) return; + for (const [k, record] of store) { + if (now - record.first >= windowMs) store.delete(k); + } + this.lastSweep.set(bucket, now); + } + /** Test helper: clear a bucket (mirrors the legacy exported maps used for resets). */ reset(bucket?: string): void { if (bucket) this.buckets.get(bucket)?.clear(); - else this.buckets.clear(); + else { this.buckets.clear(); this.lastSweep.clear(); } } } diff --git a/server/src/nest/mcp-transport/mcp-transport.service.ts b/server/src/nest/mcp-transport/mcp-transport.service.ts index 18d3e56ff..8a71d062b 100644 --- a/server/src/nest/mcp-transport/mcp-transport.service.ts +++ b/server/src/nest/mcp-transport/mcp-transport.service.ts @@ -78,6 +78,15 @@ export function jsonRpcError(message: string, code = -32000): object { return { jsonrpc: '2.0', error: { code, message }, id: null }; } +/** Scope sets as the session froze them: null (full access) only matches null. */ +export function sameScopes(a: string[] | null, b: string[] | null): boolean { + if (a === null || b === null) return a === b; + if (a.length !== b.length) return false; + const left = [...a].sort(); + const right = [...b].sort(); + return left.every((scope, i) => scope === right[i]); +} + export function setAuthChallenge(res: Response, error = 'invalid_token'): void { const base = (getMcpSafeUrl() || '').replace(/\/+$/, ''); // RFC 9728 §5: resource with path component /mcp → PRM URL must include the path @@ -176,6 +185,15 @@ export class McpTransportService { res.status(403).json({ error: 'Session was created with a different OAuth client' }); return; } + // The tool surface is frozen at initialize (registerTools takes the scopes + // the session was created with), so a token that was later narrowed would + // otherwise ride the old session's full set of tools. Refusing here makes + // the client re-initialize, which is what re-authorizing should cost. + if (!sameScopes(session.scopes, scopes)) { + setAuthChallenge(res, 'insufficient_scope'); + res.status(403).json({ error: 'Session was created with different scopes' }); + return; + } session.lastActivity = Date.now(); session.lastClientIp = getClientIp(req); armSseKeepalive(res, () => { session.lastActivity = Date.now(); }); @@ -273,6 +291,17 @@ export class McpTransportService { sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sid) => { sessions.set(sid, { server, transport, userId: user.id, scopes, clientId, isStaticToken, lastActivity: Date.now(), lastClientIp: createIp }); + // The cap was checked before the handler ran, and registration only + // happens here — so concurrent initializes all passed the same + // pre-registration count. Re-check now that the entry exists, or the + // overshoot stays in the map (each entry holding a full McpServer) + // until the TTL sweep. The new session has the freshest lastActivity, + // so it is never the one evicted; the sid guard covers a cap of 1. + while (countSessionsForUser(user.id) > MAX_SESSIONS_PER_USER) { + const dropped = evictOldestSessionForUser(user.id); + if (!dropped || dropped === sid) break; + console.log(`[MCP] Session limit (${MAX_SESSIONS_PER_USER}) exceeded for user ${user.id} — evicted idle session ${dropped}`); + } const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt'; console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`); }, diff --git a/server/src/nest/oauth/oauth-api.controller.ts b/server/src/nest/oauth/oauth-api.controller.ts index 2a406998a..bd3138fc7 100644 --- a/server/src/nest/oauth/oauth-api.controller.ts +++ b/server/src/nest/oauth/oauth-api.controller.ts @@ -73,13 +73,6 @@ export class OauthApiController { if (!this.oauth.mcpEnabled()) { throw new HttpException({ error: 'MCP is not enabled' }, 403); } - if (!body.approved) { - const url = new URL(body.redirect_uri); - url.searchParams.set('error', 'access_denied'); - url.searchParams.set('error_description', 'User denied the authorization request'); - if (body.state) url.searchParams.set('state', body.state); - return { redirect: url.toString() }; - } const params: AuthorizeParams = { response_type: 'code', client_id: body.client_id, @@ -90,10 +83,21 @@ export class OauthApiController { code_challenge_method: body.code_challenge_method, resource: body.resource, }; + // Validate before branching on approved: the deny path builds a redirect out + // of body.redirect_uri too, and only validateAuthorizeRequest checks it + // against the client's registered URIs. A string that is not a URL at all + // would additionally throw out of `new URL()` as a 500. const validation = this.oauth.validateAuthorizeRequest(params, user.id); if (!validation.valid) { throw new HttpException({ error: validation.error, error_description: validation.error_description }, 400); } + if (!body.approved) { + const url = new URL(body.redirect_uri); + url.searchParams.set('error', 'access_denied'); + url.searchParams.set('error_description', 'User denied the authorization request'); + if (body.state) url.searchParams.set('state', body.state); + return { redirect: url.toString() }; + } const scopes = validation.scopes!; this.oauth.saveConsent(body.client_id, user.id, scopes, ip); const code = this.oauth.createAuthCode({ diff --git a/server/src/nest/oauth/oauth-sdk.provider.ts b/server/src/nest/oauth/oauth-sdk.provider.ts index 1f44dcc67..961832f77 100644 --- a/server/src/nest/oauth/oauth-sdk.provider.ts +++ b/server/src/nest/oauth/oauth-sdk.provider.ts @@ -173,7 +173,9 @@ export class TrekOAuthProvider implements OAuthServerProvider { if (pending.resource && resourceStr && pending.resource !== resourceStr) throw new Error('Authorization grant is invalid.'); - if (codeVerifier && !this.oauth.verifyPKCE(codeVerifier, pending.codeChallenge)) + // A missing verifier is a failed exchange, not a skipped check — the live + // token endpoint refuses it the same way (oauth-public.controller.ts). + if (!codeVerifier || !this.oauth.verifyPKCE(codeVerifier, pending.codeChallenge)) throw new Error('Authorization grant is invalid.'); const tokens = this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null); diff --git a/server/src/nest/permissions/permissions.service.ts b/server/src/nest/permissions/permissions.service.ts index e71b5ba96..cd964e921 100644 --- a/server/src/nest/permissions/permissions.service.ts +++ b/server/src/nest/permissions/permissions.service.ts @@ -78,7 +78,7 @@ export class PermissionsService { private loadPermissions(): Map { const cached = getPermissionsCache(); if (cached) return cached; - const cache = setPermissionsCache(new Map()); + const cache = new Map(); try { const rows = this.dbs.all<{ key: string; value: string }>( "SELECT key, value FROM app_settings WHERE key LIKE 'perm_%'" @@ -99,8 +99,14 @@ export class PermissionsService { // real DB failure that must not stay invisible (we still serve defaults). const msg = e instanceof Error ? e.message : String(e); if (!msg.includes('no such table')) logError(`Permissions load failed: ${msg}`); + // Serve defaults for THIS call, but do not install them: a half-built + // cache would freeze every later reader on the defaults until somebody + // invalidates by hand, which is how a stricter admin setting silently + // stops applying. The next call retries the read instead. + return cache; } - return cache; + // Only a completed read becomes the shared cache. + return setPermissionsCache(cache); } invalidatePermissionsCache(): void { diff --git a/server/src/nest/plugins/plugin-frame.controller.ts b/server/src/nest/plugins/plugin-frame.controller.ts index 24d7aa3c5..0545cd0bb 100644 --- a/server/src/nest/plugins/plugin-frame.controller.ts +++ b/server/src/nest/plugins/plugin-frame.controller.ts @@ -59,6 +59,23 @@ export class PluginFrameController { res.status(404).send('Not found'); return; } + // The check above is lexical, and statSync follows links, so a symlink inside + // client/ still reads whatever it points at. Re-check both sides resolved: + // a dev-linked plugin is a symlinked tree, so comparing against the raw root + // would reject it. + let realRoot: string; + let realFile: string; + try { + realRoot = fs.realpathSync(path.resolve(clientDir)); + realFile = fs.realpathSync(resolved); + } catch { + res.status(404).send('Not found'); + return; + } + if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) { + res.status(403).send('Forbidden'); + return; + } const ext = path.extname(resolved).toLowerCase(); res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream'); diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index d939988f8..4522b6ba6 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -166,8 +166,9 @@ export class PluginsService { /** * Merge instance-scope settings into the plugin's config, encrypting fields - * declared secret (unless the value is the unchanged mask sentinel). Returns - * the config with secrets masked for the client. + * declared secret (unless the value is the unchanged mask sentinel). Only keys + * declared as `scope:'instance'` fields are accepted, same as updateUserConfig. + * Returns the config with secrets masked for the client. */ updateInstanceConfig(id: string, patch: Record): Record { const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined; @@ -181,8 +182,17 @@ export class PluginsService { ).map((r) => r.field_key), ); + const allowed = new Set( + ( + this.db + .prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'") + .all(id) as Array<{ field_key: string }> + ).map((r) => r.field_key), + ); + const config = safeParse(row.config); for (const [k, v] of Object.entries(patch)) { + if (!allowed.has(k)) continue; // never store an undeclared key if (secretKeys.has(k)) { if (v === SECRET_MASK) continue; // unchanged secret — keep stored ciphertext config[k] = maybe_encrypt_api_key(v); diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index 906086795..50e5e943e 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -250,6 +250,22 @@ describe('MCP session management', () => { expect(sessions.has(firstSessionId)).toBe(false); // the least-recently-active one made room }); + it('MCP-006 — initializes that race past the cap check are trimmed when they register', async () => { + const { user } = createUser(testDb); + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + + const sessionsForUser = () => [...sessions.values()].filter((s) => s.userId === user.id).length; + + for (let i = 0; i < 20; i++) await createSession(user.id); + expect(sessionsForUser()).toBe(20); + + // All five read the same pre-registration count, so all five pass the cap + // check. Registration is where the overshoot has to be given back — nothing + // else drops these entries before the TTL sweep, and each holds a server. + await Promise.all([1, 2, 3, 4, 5].map(() => createSession(user.id))); + expect(sessionsForUser()).toBe(20); + }); + it('MCP — session resumption with valid mcp-session-id', async () => { const { user } = createUser(testDb); testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); @@ -433,6 +449,43 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => { expect(res.headers['www-authenticate']).toBe(EXPECTED_CHALLENGE); }); + it('MCP-P08b — a narrower token cannot resume a session that was created with wider scopes', async () => { + const { user } = createUser(testDb); + const created = oauthSvc.createOAuthClient(user.id, 'Scope Test Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']); + const clientId = (created.client as { client_id: string }).client_id; + const wide = oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE); + const narrow = oauthSvc.issueTokens(clientId, user.id, ['trips:read'], null, MCP_AUDIENCE); + const sessionId = await createSession(wide.access_token); + + // The tool surface was registered from the wide set; resuming with the + // narrow one would ride it. + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${narrow.access_token}`) + .set('mcp-session-id', sessionId) + .send({ jsonrpc: '2.0', method: 'tools/list', id: 2 }); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'Session was created with different scopes' }); + expect(res.headers['www-authenticate']).toContain('error="insufficient_scope"'); + }); + + it('MCP-P08c — the same scopes in a different order still resume', async () => { + const { user } = createUser(testDb); + const created = oauthSvc.createOAuthClient(user.id, 'Scope Order Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']); + const clientId = (created.client as { client_id: string }).client_id; + const first = oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE); + const reordered = oauthSvc.issueTokens(clientId, user.id, ['trips:write', 'trips:read'], null, MCP_AUDIENCE); + const sessionId = await createSession(first.access_token); + + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${reordered.access_token}`) + .set('mcp-session-id', sessionId) + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 2 }); + expect(res.status).toBe(200); + }); + it('MCP-P09 — a static-token session carries the deprecation notice on the first tool call only', async () => { const { user } = createUser(testDb); const { rawToken } = createMcpToken(testDb, user.id); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 4c06f37be..30f8725a1 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -820,22 +820,44 @@ describe('POST /api/oauth/authorize', () => { it('OAUTH-030 — user denied returns redirect with error=access_denied', async () => { const { user } = createUser(testDb); + const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const { challenge } = makePkce(); const res = await request(app) .post('/api/oauth/authorize') .set('Cookie', authCookie(user.id)) .send({ approved: false, - client_id: 'any', + client_id: r.client!.client_id, redirect_uri: 'https://app.example.com/cb', scope: 'trips:read', - code_challenge: 'c', + code_challenge: challenge, code_challenge_method: 'S256', }); expect(res.status).toBe(200); expect(res.body.redirect).toContain('error=access_denied'); }); + it('OAUTH-030b — a denial for an unregistered redirect_uri is refused, not redirected', async () => { + const { user } = createUser(testDb); + const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const { challenge } = makePkce(); + + const res = await request(app) + .post('/api/oauth/authorize') + .set('Cookie', authCookie(user.id)) + .send({ + approved: false, + client_id: r.client!.client_id, + redirect_uri: 'https://attacker.example.com/cb', + scope: 'trips:read', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_redirect_uri'); + }); + it('OAUTH-031 — invalid params returns 400', async () => { const { user } = createUser(testDb); const { challenge } = makePkce(); diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index 07b179b59..0baaead11 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import express from 'express'; import request from 'supertest'; import { applyGlobalMiddleware } from '../../../src/middleware/globalMiddleware'; -async function connectSrcSources(): Promise { +async function directiveSources(name: string): Promise { const app = express(); applyGlobalMiddleware(app); app.get('/probe', (_req, res) => res.json({ ok: true })); @@ -13,11 +13,13 @@ async function connectSrcSources(): Promise { const directive = csp .split(';') .map(d => d.trim()) - .find(d => d.startsWith('connect-src')); + .find(d => d.startsWith(name)); return directive ? directive.split(/\s+/).slice(1) : []; } +const connectSrcSources = () => directiveSources('connect-src'); + describe('global CSP: OpenStreetMap tile hosts (#1733)', () => { it('allows the bare tile.openstreetmap.org host', async () => { // A CSP wildcard host never matches the apex, so `*.tile.openstreetmap.org` @@ -30,3 +32,44 @@ describe('global CSP: OpenStreetMap tile hosts (#1733)', () => { expect(await connectSrcSources()).toContain('https://*.tile.openstreetmap.org'); }); }); + +describe('global CSP: script-src', () => { + it("allows 'wasm-unsafe-eval' so the WASM decoders keep running", async () => { + expect(await directiveSources('script-src')).toContain("'wasm-unsafe-eval'"); + }); + + it("does not allow 'unsafe-eval' — the policy the wiki documents, and nothing first-party evals", async () => { + expect(await directiveSources('script-src')).not.toContain("'unsafe-eval'"); + }); +}); + +describe('forced-HTTPS redirect', () => { + const saved = { FORCE_HTTPS: process.env.FORCE_HTTPS, APP_URL: process.env.APP_URL }; + + afterEach(() => { + process.env.FORCE_HTTPS = saved.FORCE_HTTPS; + process.env.APP_URL = saved.APP_URL; + if (saved.FORCE_HTTPS === undefined) delete process.env.FORCE_HTTPS; + if (saved.APP_URL === undefined) delete process.env.APP_URL; + }); + + async function redirectLocation(): Promise { + const app = express(); + applyGlobalMiddleware(app); + app.get('/trips', (_req, res) => res.json({ ok: true })); + const res = await request(app).get('/trips').set('Host', 'evil.example.com'); + return String(res.headers.location || ''); + } + + it('redirects to the configured APP_URL host, not the Host header the caller sent', async () => { + process.env.FORCE_HTTPS = 'true'; + process.env.APP_URL = 'https://trip.pakulat.org'; + expect(await redirectLocation()).toBe('https://trip.pakulat.org/trips'); + }); + + it('falls back to the request host when APP_URL is unset', async () => { + process.env.FORCE_HTTPS = 'true'; + delete process.env.APP_URL; + expect(await redirectLocation()).toBe('https://evil.example.com/trips'); + }); +}); diff --git a/server/tests/unit/middleware/globalMiddleware.redaction.test.ts b/server/tests/unit/middleware/globalMiddleware.redaction.test.ts index 63f116b1a..ed4116b43 100644 --- a/server/tests/unit/middleware/globalMiddleware.redaction.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.redaction.test.ts @@ -45,6 +45,25 @@ describe('globalMiddleware request-log redaction', () => { }); }); + it('redacts secret-shaped keys by suffix — the OAuth token body and the settings API keys', () => { + const out = redact({ + refresh_token: 'rt-1', + code_verifier: 'cv-1', + llm_api_key: 'sk-1', + mapbox_access_token: 'pk-1', + grant_type: 'refresh_token', + }) as Record; + expect(out.refresh_token).toBe('[REDACTED]'); + expect(out.code_verifier).toBe('[REDACTED]'); + expect(out.llm_api_key).toBe('[REDACTED]'); + expect(out.mapbox_access_token).toBe('[REDACTED]'); + expect(out.grant_type).toBe('refresh_token'); + }); + + it('matches the suffix on the end of the key only, so identifiers stay readable', () => { + expect(redact({ accessKeyId: 'ak-1', tokenCount: 5 })).toEqual({ accessKeyId: 'ak-1', tokenCount: 5 }); + }); + it('passes non-object values through untouched', () => { expect(redact('plain string')).toBe('plain string'); expect(redact(42)).toBe(42); diff --git a/server/tests/unit/nest/audit.service.test.ts b/server/tests/unit/nest/audit.service.test.ts index 3c9499218..e37c5ec15 100644 --- a/server/tests/unit/nest/audit.service.test.ts +++ b/server/tests/unit/nest/audit.service.test.ts @@ -61,10 +61,12 @@ afterAll(() => { }); function makeReq(options: { + ip?: string; xff?: string | string[]; remoteAddress?: string; } = {}): Request { return { + ip: options.ip, headers: { ...(options.xff !== undefined ? { 'x-forwarded-for': options.xff } : {}), }, @@ -75,33 +77,34 @@ function makeReq(options: { // ── getClientIp (pure, moved 1:1) ───────────────────────────────────────────── describe('getClientIp', () => { - it('AUDIT-SVC-001: returns first IP from comma-separated X-Forwarded-For string', () => { - expect(getClientIp(makeReq({ xff: '1.2.3.4, 5.6.7.8, 9.10.11.12' }))).toBe('1.2.3.4'); + it('AUDIT-SVC-001: returns req.ip, which Express resolved through the trust-proxy hop count', () => { + expect(getClientIp(makeReq({ ip: '1.2.3.4', xff: '9.9.9.9, 1.2.3.4' }))).toBe('1.2.3.4'); }); - it('AUDIT-SVC-002: returns single IP when X-Forwarded-For has no comma', () => { - expect(getClientIp(makeReq({ xff: '10.0.0.1' }))).toBe('10.0.0.1'); + it('AUDIT-SVC-002: ignores a hand-written X-Forwarded-For — the leftmost entry is whatever the caller typed', () => { + expect(getClientIp(makeReq({ ip: '203.0.113.7', xff: '10.0.0.1' }))).toBe('203.0.113.7'); }); - it('AUDIT-SVC-003: returns first element when X-Forwarded-For is an array', () => { - expect(getClientIp(makeReq({ xff: ['203.0.113.1', '10.0.0.1'] }))).toBe('203.0.113.1'); + it('AUDIT-SVC-003: ignores an array-valued X-Forwarded-For too', () => { + expect(getClientIp(makeReq({ ip: '203.0.113.7', xff: ['203.0.113.1', '10.0.0.1'] }))).toBe('203.0.113.7'); }); - it('AUDIT-SVC-004: trims whitespace from extracted IP', () => { - expect(getClientIp(makeReq({ xff: ' 192.168.1.1 , 10.0.0.1' }))).toBe('192.168.1.1'); + it('AUDIT-SVC-004: trims whitespace from the resolved IP', () => { + expect(getClientIp(makeReq({ ip: ' 192.168.1.1 ' }))).toBe('192.168.1.1'); }); - it('AUDIT-SVC-005: falls back to req.socket.remoteAddress when no X-Forwarded-For', () => { + it('AUDIT-SVC-005: falls back to req.socket.remoteAddress when req.ip is unset', () => { expect(getClientIp(makeReq({ remoteAddress: '172.16.0.1' }))).toBe('172.16.0.1'); }); - it('AUDIT-SVC-006: returns null when no forwarded header and no socket address', () => { + it('AUDIT-SVC-006: returns null when there is neither a resolved IP nor a socket address', () => { expect(getClientIp(makeReq({}))).toBeNull(); }); - it('AUDIT-SVC-007: returns null for empty string X-Forwarded-For', () => { + it('AUDIT-SVC-007: returns null for an empty req.ip with no socket address', () => { const req = { - headers: { 'x-forwarded-for': '' }, + ip: '', + headers: { 'x-forwarded-for': '203.0.113.9' }, socket: { remoteAddress: undefined }, } as unknown as Request; expect(getClientIp(req)).toBeNull(); @@ -147,6 +150,19 @@ describe('writeAudit', () => { expect(logInfo).toHaveBeenLastCalledWith('anonymous logged in ip=9.9.9.9'); }); + it('AUDIT-SVC-019: a trip title with a line break cannot forge a second log line', () => { + seedUser(1, 'a@b.c'); + svc.writeAudit({ + userId: 1, + action: 'trip.create', + details: { title: 'Rome\nadmin@b.c changed user role' }, + ip: '1.2.3.4', + }); + const logged = String((logInfo as unknown as { mock: { calls: string[][] } }).mock.calls.at(-1)?.[0]); + expect(logged).not.toContain('\n'); + expect(logged).toBe('a@b.c created trip "Rome admin@b.c changed user role" ip=1.2.3.4'); + }); + it('AUDIT-SVC-011: omitted resource/ip store NULL and the log line ends ip=-', () => { seedUser(1, 'a@b.c'); svc.writeAudit({ userId: 1, action: 'user.login' }); diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index b3f2afea2..0078e4196 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -249,8 +249,23 @@ describe('AuthController (authenticated)', () => { }); it('resource-token 503 when unavailable, else returns the token payload', () => { - expect(thrown(() => ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue(null) }).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } }); - expect(ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' }); + expect(thrown(() => ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue(null) }).resourceToken(user, {}, req))).toEqual({ status: 503, body: { error: 'Service unavailable' } }); + expect(ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, { purpose: 'download' }, req)).toEqual({ token: 'rt' }); + }); + + it('ws/resource tokens throttle on their own buckets, so a mint loop cannot drain the store or block login', () => { + const s = rl(); + const now = Date.now(); + for (let i = 0; i < 120; i++) s.check('ws_token', '9.9.9.9', 120, 15 * 60 * 1000, now); + expect(thrown(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user, req))) + .toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + // The login bucket is untouched: an exhausted socket loop must not lock the account out. + expect(s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now)).toBe(true); + + const s2 = rl(); + for (let i = 0; i < 120; i++) s2.check('resource_token', '9.9.9.9', 120, 15 * 60 * 1000, now); + expect(thrown(() => ac(asvc({}), s2, { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, {}, req))) + .toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('rate-limited account ops throw 429 once the bucket is exhausted', () => { @@ -385,8 +400,8 @@ describe('AuthController (authenticated)', () => { }); it('ws-token maps error, else returns the token', () => { - expect(thrown(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } }); - expect(ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)).toEqual({ token: 'ws' }); + expect(thrown(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user, req))).toEqual({ status: 503, body: { error: 'down' } }); + expect(ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user, req)).toEqual({ token: 'ws' }); }); it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => { diff --git a/server/tests/unit/nest/idempotency.interceptor.test.ts b/server/tests/unit/nest/idempotency.interceptor.test.ts index 31d4e4915..646e601dd 100644 --- a/server/tests/unit/nest/idempotency.interceptor.test.ts +++ b/server/tests/unit/nest/idempotency.interceptor.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { CallHandler, ExecutionContext } from '@nestjs/common'; -import { of, lastValueFrom } from 'rxjs'; +import { from, of, lastValueFrom } from 'rxjs'; import { IdempotencyInterceptor } from '../../../src/nest/common/idempotency.interceptor'; import type { DatabaseService } from '../../../src/nest/database/database.service'; @@ -181,6 +181,62 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle expect(returned).toEqual({ ok: true }); }); + it('makes an overlapping replay of the same key wait, then answers it from the first response', async () => { + // The row only appears once the first request answers, so without the + // in-flight map both of these would miss the SELECT and both would run. + const rows = new Map(); + const db = makeDb({ + get: vi.fn((_sql: string, ...params: unknown[]) => rows.get(String(params[0]))) as unknown as DatabaseService['get'], + run: vi.fn((_sql: string, ...params: unknown[]) => { + rows.set(String(params[0]), { status_code: Number(params[4]), response_body: String(params[5]) }); + return {} as never; + }) as unknown as DatabaseService['run'], + }); + const interceptor = new IdempotencyInterceptor(db); + + // The first handler is still running when the second request arrives. + let finish!: (value: unknown) => void; + const slow = { handle: vi.fn(() => from(new Promise((resolve) => { finish = resolve; }))) }; + const firstRes = makeRes(); + const first = lastValueFrom(interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, firstRes), slow)); + + const secondHandler = handler({ id: 'second' }); + const secondRes = makeRes(); + const second = lastValueFrom(interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, secondRes), secondHandler)); + + firstRes.statusCode = 201; + firstRes.json({ id: 'first' }); + finish({ id: 'first' }); + + expect(await first).toEqual({ id: 'first' }); + expect(await second).toEqual({ id: 'first' }); + expect(secondHandler.handle).not.toHaveBeenCalled(); + expect(secondRes.status).toHaveBeenCalledWith(201); + }); + + it('runs the waiting request itself when the first one cached nothing', async () => { + const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() }); + const interceptor = new IdempotencyInterceptor(db); + + let finish!: (value: unknown) => void; + const slow = { handle: vi.fn(() => from(new Promise((resolve) => { finish = resolve; }))) }; + const first = lastValueFrom(interceptor.intercept( + ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, makeRes()), + slow, + )); + + const secondHandler = handler({ id: 'second' }); + const second = lastValueFrom(interceptor.intercept( + ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, makeRes()), + secondHandler, + )); + + finish({ id: 'first' }); + await first; + expect(await second).toEqual({ id: 'second' }); + expect(secondHandler.handle).toHaveBeenCalled(); + }); + it('treats a PATCH as a mutating method', async () => { const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() }); const res = makeRes(); diff --git a/server/tests/unit/nest/mcp-transport.service.test.ts b/server/tests/unit/nest/mcp-transport.service.test.ts index f5c1de0c1..4755c8e6c 100644 --- a/server/tests/unit/nest/mcp-transport.service.test.ts +++ b/server/tests/unit/nest/mcp-transport.service.test.ts @@ -15,6 +15,7 @@ import { armSseKeepalive, countSessionsForUser, jsonRpcError, + sameScopes, setAuthChallenge, } from '../../../src/nest/mcp-transport/mcp-transport.service'; import { McpTransportController } from '../../../src/nest/mcp-transport/mcp-transport.controller'; @@ -70,6 +71,21 @@ describe('setAuthChallenge', () => { }); }); +describe('sameScopes', () => { + it('MCPT-008: full access only matches full access', () => { + expect(sameScopes(null, null)).toBe(true); + expect(sameScopes(null, ['trips:read'])).toBe(false); + expect(sameScopes(['trips:read'], null)).toBe(false); + }); + + it('MCPT-009: order does not matter, membership does', () => { + expect(sameScopes(['a', 'b'], ['b', 'a'])).toBe(true); + expect(sameScopes(['a', 'b'], ['a'])).toBe(false); + expect(sameScopes(['a'], ['b'])).toBe(false); + expect(sameScopes([], [])).toBe(true); + }); +}); + describe('countSessionsForUser', () => { afterEach(() => sessions.clear()); diff --git a/server/tests/unit/nest/oauth-sdk.provider.test.ts b/server/tests/unit/nest/oauth-sdk.provider.test.ts index c113e8340..7cd6a8033 100644 --- a/server/tests/unit/nest/oauth-sdk.provider.test.ts +++ b/server/tests/unit/nest/oauth-sdk.provider.test.ts @@ -374,10 +374,15 @@ describe('TrekOAuthProvider.exchangeAuthorizationCode', () => { it('SDKP-046: a pending code without a resource issues a null-audience token', async () => { const oauth = makeOauth({ consumeAuthCode: vi.fn(() => pending({ resource: null })) }); const { provider } = makeProvider(oauth); - await provider.exchangeAuthorizationCode(clientInfo(), 'code'); + await provider.exchangeAuthorizationCode(clientInfo(), 'code', 'verifier'); expect(oauth.issueTokens).toHaveBeenCalledWith('cid-1', 7, ['trips:read'], null, null); - // No codeVerifier was supplied, so PKCE verification is skipped entirely. - expect(oauth.verifyPKCE).not.toHaveBeenCalled(); + }); + + it('SDKP-047: a missing codeVerifier is refused, not waved through', async () => { + const oauth = makeOauth({ consumeAuthCode: vi.fn(() => pending()) }); + const { provider } = makeProvider(oauth); + await expect(provider.exchangeAuthorizationCode(clientInfo(), 'code')).rejects.toThrow(invalid); + expect(oauth.issueTokens).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/oauth.controller.test.ts b/server/tests/unit/nest/oauth.controller.test.ts index 2d0c407de..f111229f6 100644 --- a/server/tests/unit/nest/oauth.controller.test.ts +++ b/server/tests/unit/nest/oauth.controller.test.ts @@ -339,7 +339,8 @@ describe('OauthApiController', () => { }); it('authorize: denied returns a redirect with access_denied, approved issues a code', () => { - const denied = new OauthApiController(osvc(), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); + const deniedSvc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }) }); + const denied = new OauthApiController(deniedSvc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); expect((denied as { redirect: string }).redirect).toContain('error=access_denied'); const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') }); const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req); @@ -405,7 +406,8 @@ describe('OauthApiController', () => { }); it('authorize: carries the state through both the denied and approved redirects', () => { - const denied = new OauthApiController(osvc(), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); + const deniedSvc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'] }) }); + const denied = new OauthApiController(deniedSvc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', state: 'xyz', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req); expect((denied as { redirect: string }).redirect).toContain('state=xyz'); const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: 'https://aud' }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') }); @@ -414,6 +416,15 @@ describe('OauthApiController', () => { expect((ok as { redirect: string }).redirect).toContain('state=xyz'); }); + it('authorize: a denial is validated too, so the redirect can only be a registered URI', () => { + const validateAuthorizeRequest = vi.fn().mockReturnValue({ valid: false, error: 'invalid_redirect_uri', error_description: 'Invalid redirect URI' }); + const svc = osvc({ validateAuthorizeRequest, saveConsent: vi.fn(), createAuthCode: vi.fn() }); + expect(thrown(() => new OauthApiController(svc, rl()) + .authorize(user, { client_id: 'c', redirect_uri: 'not a url', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req))) + .toEqual({ status: 400, body: { error: 'invalid_redirect_uri', error_description: 'Invalid redirect URI' } }); + expect(validateAuthorizeRequest).toHaveBeenCalled(); + }); + it('client/session errors default the status to 400 when the service omits it', () => { expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'bad' }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req))) .toEqual({ status: 400, body: { error: 'bad' } }); diff --git a/server/tests/unit/nest/permissions.service.test.ts b/server/tests/unit/nest/permissions.service.test.ts index 97342743b..e38e200ab 100644 --- a/server/tests/unit/nest/permissions.service.test.ts +++ b/server/tests/unit/nest/permissions.service.test.ts @@ -43,7 +43,10 @@ import { runMigrations } from '../../../src/db/migrations'; import { logError } from '../../../src/nest/audit/audit-log.logger'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService, PERMISSION_ACTIONS } from '../../../src/nest/permissions/permissions.service'; -import { invalidatePermissionsCache as invalidateSharedCache } from '../../../src/nest/permissions/permissions-cache'; +import { + getPermissionsCache, + invalidatePermissionsCache as invalidateSharedCache, +} from '../../../src/nest/permissions/permissions-cache'; const svc = new PermissionsService(new DatabaseService(testDb)); @@ -192,6 +195,22 @@ describe('load failures', () => { svc.invalidatePermissionsCache(); // don't leak the bare-DB cache to later tests }); + it('PERM-SVC-024: a failed read serves defaults without installing them, and a later read populates the cache', () => { + const failing = { all: vi.fn((): { key: string; value: string }[] => { throw new Error('database connection is closed'); }) }; + const flakySvc = new PermissionsService(failing as unknown as DatabaseService); + flakySvc.invalidatePermissionsCache(); + + expect(flakySvc.getPermissionLevel('trip_edit')).toBe('trip_owner'); + // Nothing installed: an admin's stricter stored level would otherwise stay + // invisible until somebody invalidated by hand. + expect(getPermissionsCache()).toBe(null); + + failing.all.mockReturnValue([{ key: 'perm_trip_edit', value: 'trip_member' }]); + expect(flakySvc.getPermissionLevel('trip_edit')).toBe('trip_member'); + expect(getPermissionsCache()).not.toBe(null); + svc.invalidatePermissionsCache(); // don't leak the stub cache to later tests + }); + it('PERM-SVC-023: an all-skipped save writes nothing and leaves the cache untouched', () => { expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); // prime the cache testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member'); diff --git a/server/tests/unit/nest/rate-limit.service.test.ts b/server/tests/unit/nest/rate-limit.service.test.ts new file mode 100644 index 000000000..b0c9d7e73 --- /dev/null +++ b/server/tests/unit/nest/rate-limit.service.test.ts @@ -0,0 +1,76 @@ +/** + * RateLimitService — the per-IP buckets every auth-adjacent route throttles on. + * The limit behaviour is the legacy one line for line; what is worth pinning + * here is that the maps do not grow forever, because the keys come from + * proxy headers and are therefore attacker-influenced. + */ +import { describe, it, expect } from 'vitest'; +import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; + +const WINDOW = 15 * 60 * 1000; + +/** The private bucket map, which is the only place the housekeeping is visible. */ +function bucketSize(service: RateLimitService, bucket: string): number { + const buckets = (service as unknown as { buckets: Map> }).buckets; + return buckets.get(bucket)?.size ?? 0; +} + +describe('RateLimitService', () => { + it('allows up to max attempts in a window, then refuses', () => { + const s = new RateLimitService(); + const now = Date.now(); + for (let i = 0; i < 5; i++) expect(s.check('login', '1.2.3.4', 5, WINDOW, now)).toBe(true); + expect(s.check('login', '1.2.3.4', 5, WINDOW, now)).toBe(false); + }); + + it('starts a fresh window once the old one elapsed', () => { + const s = new RateLimitService(); + const now = Date.now(); + for (let i = 0; i < 5; i++) s.check('login', '1.2.3.4', 5, WINDOW, now); + expect(s.check('login', '1.2.3.4', 5, WINDOW, now + WINDOW)).toBe(true); + }); + + it('drops expired records instead of keeping one per address that ever knocked', () => { + const s = new RateLimitService(); + const now = Date.now(); + for (let i = 0; i < 100; i++) s.check('login', `10.0.0.${i}`, 5, WINDOW, now); + expect(bucketSize(s, 'login')).toBe(100); + + // One request a window later sweeps the hundred stale keys and leaves its own. + s.check('login', '10.0.0.0', 5, WINDOW, now + WINDOW); + expect(bucketSize(s, 'login')).toBe(1); + }); + + it('sweeps each bucket on its own clock, so a busy bucket cannot starve a quiet one', () => { + const s = new RateLimitService(); + const now = Date.now(); + s.check('login', '1.2.3.4', 5, WINDOW, now); + for (let i = 0; i < 3; i++) s.check('mfa', `10.0.0.${i}`, 5, WINDOW, now + WINDOW * (i + 1)); + expect(bucketSize(s, 'login')).toBe(1); + + s.check('login', '5.6.7.8', 5, WINDOW, now + WINDOW); + expect(bucketSize(s, 'login')).toBe(1); + }); + + it('keeps a record that is still inside its window when the sweep runs', () => { + const s = new RateLimitService(); + const now = Date.now(); + s.check('login', '1.2.3.4', 5, WINDOW, now); + s.check('login', '5.6.7.8', 5, WINDOW, now + WINDOW); + // The second key was written after the sweep, the first one was expired. + expect(bucketSize(s, 'login')).toBe(1); + expect(s.check('login', '1.2.3.4', 5, WINDOW, now + WINDOW)).toBe(true); + }); + + it('reset clears a single bucket, or all of them', () => { + const s = new RateLimitService(); + const now = Date.now(); + s.check('login', '1.2.3.4', 5, WINDOW, now); + s.check('mfa', '1.2.3.4', 5, WINDOW, now); + s.reset('login'); + expect(bucketSize(s, 'login')).toBe(0); + expect(bucketSize(s, 'mfa')).toBe(1); + s.reset(); + expect(bucketSize(s, 'mfa')).toBe(0); + }); +}); diff --git a/server/tests/unit/plugins/plugin-frame.test.ts b/server/tests/unit/plugins/plugin-frame.test.ts index 0d59379e0..7c1428b7d 100644 --- a/server/tests/unit/plugins/plugin-frame.test.ts +++ b/server/tests/unit/plugins/plugin-frame.test.ts @@ -15,16 +15,36 @@ import { PluginFrameController } from '../../../src/nest/plugins/plugin-frame.co import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; let codeRoot: string; +let outsideRoot: string; +// Windows only lets an unprivileged process create a directory junction. +const dirLink = process.platform === 'win32' ? 'junction' : 'dir'; +let symlinksAvailable = true; + beforeAll(() => { codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-frame-')); + outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-outside-')); process.env.TREK_PLUGINS_DIR = codeRoot; const dir = path.join(codeRoot, 'widget', 'client'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.html'), 'hi'); + + // Something worth stealing, plus a link out of client/ that points at it. + fs.mkdirSync(path.join(outsideRoot, 'secrets'), { recursive: true }); + fs.writeFileSync(path.join(outsideRoot, 'secrets', 'leak.js'), 'TOP SECRET'); + // A dev-linked plugin: the whole tree is a link, which must still be served. + fs.mkdirSync(path.join(outsideRoot, 'linked', 'client'), { recursive: true }); + fs.writeFileSync(path.join(outsideRoot, 'linked', 'client', 'index.html'), 'dev'); + try { + fs.symlinkSync(path.join(outsideRoot, 'secrets'), path.join(dir, 'esc'), dirLink); + fs.symlinkSync(path.join(outsideRoot, 'linked'), path.join(codeRoot, 'linked'), dirLink); + } catch { + symlinksAvailable = false; + } }); afterAll(() => { delete process.env.TREK_PLUGINS_DIR; fs.rmSync(codeRoot, { recursive: true, force: true }); + fs.rmSync(outsideRoot, { recursive: true, force: true }); }); function fakeRes() { @@ -157,4 +177,23 @@ describe('PluginFrameController', () => { new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never); expect(res.statusCode).toBe(404); }); + + // The lexical guard above cannot see through a link, and statSync follows one. + it('403 on a symlink inside client/ that points out of the plugin', () => { + if (!symlinksAvailable) return; + const res = fakeRes(); + new PluginFrameController(runtime(true)).serve('widget', req('esc/leak.js'), res as never); + expect(res.statusCode).toBe(403); + expect(res.filePath).toBeUndefined(); + }); + + // Dev-link mode: the plugin directory itself is a link, so the containment + // check has to resolve BOTH sides or local development stops working. + it('still serves a dev-linked plugin whose whole tree is a symlink', () => { + if (!symlinksAvailable) return; + const res = fakeRes(); + new PluginFrameController(runtime(true)).serve('linked', req(''), res as never); + expect(res.statusCode).toBe(200); + expect(res.filePath).toContain('index.html'); + }); }); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 45f10baa5..a3d931427 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -246,6 +246,7 @@ describe('PluginsService instance config', () => { it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', () => { testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run(); testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run(); + testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','server','instance',0)").run(); const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))); const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' }); @@ -267,6 +268,19 @@ describe('PluginsService instance config', () => { expect(svc.getInstanceConfig('x').api_key).toBe('••••••••'); }); + it('drops a key the plugin never declared, like the user-scope sibling does', () => { + testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('y','Y','inactive','{}')").run(); + testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('y','server','instance',0)").run(); + + const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))); + const masked = svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' }); + + expect(masked.server).toBe('https://h'); + expect(masked.smuggled).toBeUndefined(); + const stored = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='y'").get() as { config: string }).config); + expect(stored).toEqual({ server: 'https://h' }); + }); + it('throws for an unknown plugin', () => { expect(() => new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).updateInstanceConfig('nope', {})).toThrow(/not found/); expect(() => new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).getInstanceConfig('nope')).toThrow(/not found/); From a7aaa29c1680bb7c12c8351f20fd7433af4903d2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:48:12 +0200 Subject: [PATCH 09/25] fix(db): stop losing secrets and notes to the boot and migration paths An unreadable or empty encryption-key file was treated the same as a missing one: regenerate and overwrite. That is the one case where regenerating is the worst possible move - every secret already at rest becomes undecryptable, permanently. Both now refuse to start and say which file to fix. The key-rotation script advertised re-encrypting all stored secrets while its hardcoded column lists had drifted: the AirTrail key, the per-user LLM key and the plugin OAuth tokens were never migrated, so they silently stopped decrypting after a rotation. The parity test now pins the full set against what the code actually encrypts, rather than two tables of it. The schema_version bump ran outside the migration's transaction, so a crash in between left the schema advanced and the version stale - the next boot replayed a non-idempotent step and exited, turning one crash into a boot loop. And the TICKETJSON receipt migration matched with LIKE, which SQLite treats case-insensitively, so a note somebody had typed starting 'ticketjson:' was cut up and its text dropped. Also: APP_VERSION was interpolated into migration SQL instead of bound, the capture-metadata backfill passed (photoId, userId) to a (userId, photoId) signature and its mock keyed on the wrong argument, TRUST_PROXY=0 was accepted and silently coerced to 1, demo-reset hardcoded the db path instead of honouring TREK_DB_FILE, and a demo instance with no DEMO_ADMIN_PASS set now says out loud that its admin password is the published default. --- server/scripts/migrate-encryption.ts | 119 +++++++++++- server/src/app-config/README.md | 1 - server/src/app-config/derive.ts | 7 +- server/src/config.ts | 22 ++- server/src/db/migrations.ts | 25 ++- server/src/demo/demo-reset.ts | 18 +- server/src/demo/demo-seed.ts | 13 +- .../photo-capture-backfill.service.ts | 2 +- server/src/systemNotices/service.ts | 1 - server/tests/unit/app-config/derive.test.ts | 10 +- server/tests/unit/config.test.ts | 89 +++++++++ .../db/budget-ticket-json-migration.test.ts | 57 ++++++ .../unit/db/migrate-encryption-parity.test.ts | 179 ++++++++++++++++++ .../db/migration-version-atomicity.test.ts | 55 ++++++ .../tests/unit/demo/demo-reset-path.test.ts | 40 ++++ server/tests/unit/demo/demo-seed.test.ts | 64 +++++++ .../photo-capture-backfill.service.test.ts | 13 +- 17 files changed, 699 insertions(+), 16 deletions(-) create mode 100644 server/tests/unit/config.test.ts create mode 100644 server/tests/unit/db/budget-ticket-json-migration.test.ts create mode 100644 server/tests/unit/db/migration-version-atomicity.test.ts create mode 100644 server/tests/unit/demo/demo-reset-path.test.ts create mode 100644 server/tests/unit/demo/demo-seed.test.ts diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts index 5f080e471..c00191d97 100644 --- a/server/scripts/migrate-encryption.ts +++ b/server/scripts/migrate-encryption.ts @@ -303,7 +303,22 @@ async function main() { // unsplash_api_key was missing here for as long as the column has existed. It // matters now that the resolver reads it as the per-user fallback (#1939): left // out of a rotation it stays encrypted under the old key and reads back as unset. - const apiKeyColumns = ['maps_api_key', 'unsplash_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did']; + // airtrail_api_key arrived with a later migration, so an older database may + // not have the column at all — filter the list against the real table rather + // than letting one missing column throw the whole rotation. + const userColumns = new Set( + (db.prepare("SELECT name FROM pragma_table_info('users')").all() as { name: string }[]).map((r) => r.name), + ); + const apiKeyColumns = [ + 'maps_api_key', + 'unsplash_api_key', + 'openweather_api_key', + 'immich_api_key', + 'synology_password', + 'synology_sid', + 'synology_did', + 'airtrail_api_key', + ].filter((c) => userColumns.has(c)); const users = db.prepare('SELECT id FROM users').all() as { id: number }[]; for (const user of users) { @@ -389,7 +404,7 @@ async function main() { } // --- settings: per-user encrypted keys --- - const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token']; + const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key']; const settingRows = db.prepare( `SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})` ).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[]; @@ -402,6 +417,106 @@ async function main() { } } + // --- plugins: OAuth tokens and secret settings fields --- + // The plugin host encrypts with the same enc:v1: scheme as everything above: + // plugin-oauth.service stores both tokens encrypted, and plugins.service + // encrypts every settings field the manifest marked secret (instance scope in + // plugins.config, user scope in plugin_user_config.config). Left out of a + // rotation, a connected plugin reads back as "not connected" and a secret + // setting as unset, with nothing in the log to say why. Every table here + // arrived with a later migration, so each block checks that it exists. + const tableExists = (name: string): boolean => + !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name); + + if (tableExists('plugin_oauth_tokens')) { + const tokenRows = db + .prepare('SELECT plugin_id, user_id, access_token, refresh_token FROM plugin_oauth_tokens') + .all() as { plugin_id: string; user_id: number; access_token: string | null; refresh_token: string | null }[]; + for (const row of tokenRows) { + for (const col of ['access_token', 'refresh_token'] as const) { + const raw = row[col]; + if (!raw) continue; + const newVal = migrateApiKeyValue(raw, `plugin_oauth_tokens[${row.plugin_id},${row.user_id}].${col}`); + if (newVal !== null) { + db.prepare(`UPDATE plugin_oauth_tokens SET ${col} = ? WHERE plugin_id = ? AND user_id = ?`).run( + newVal, + row.plugin_id, + row.user_id, + ); + } + } + } + } + + if (tableExists('plugin_settings_fields')) { + // Which keys are secret is per plugin and per scope, and it lives in the DB + // rather than in this script, so nothing has to be pinned by hand here. + const secretFields = db + .prepare("SELECT plugin_id, field_key, scope FROM plugin_settings_fields WHERE secret = 1") + .all() as { plugin_id: string; field_key: string; scope: string | null }[]; + const secretKeysFor = (pluginId: string, scope: string): string[] => + secretFields + .filter((f) => f.plugin_id === pluginId && (f.scope ?? 'instance') === scope) + .map((f) => f.field_key); + + const migrateConfigJson = (raw: string | null, keys: string[], label: string): string | null => { + if (!raw || keys.length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + result.errors.push(`${label}: invalid JSON — skipping`); + return null; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + result.errors.push(`${label}: not an object — skipping`); + return null; + } + const config = parsed as Record; + let changed = false; + for (const key of keys) { + const value = config[key]; + if (typeof value !== 'string' || !value) continue; + const newVal = migrateApiKeyValue(value, `${label}.${key}`); + if (newVal !== null) { + config[key] = newVal; + changed = true; + } + } + return changed ? JSON.stringify(config) : null; + }; + + if (tableExists('plugins')) { + const pluginRows = db.prepare('SELECT id, config FROM plugins').all() as { id: string; config: string | null }[]; + for (const row of pluginRows) { + const next = migrateConfigJson(row.config, secretKeysFor(row.id, 'instance'), `plugins[${row.id}].config`); + if (next !== null) { + db.prepare('UPDATE plugins SET config = ? WHERE id = ?').run(next, row.id); + } + } + } + + if (tableExists('plugin_user_config')) { + const userConfigRows = db + .prepare('SELECT plugin_id, user_id, config FROM plugin_user_config') + .all() as { plugin_id: string; user_id: number; config: string | null }[]; + for (const row of userConfigRows) { + const next = migrateConfigJson( + row.config, + secretKeysFor(row.plugin_id, 'user'), + `plugin_user_config[${row.plugin_id},${row.user_id}].config`, + ); + if (next !== null) { + db.prepare('UPDATE plugin_user_config SET config = ? WHERE plugin_id = ? AND user_id = ?').run( + next, + row.plugin_id, + row.user_id, + ); + } + } + } + } + // --- trip_album_links: passphrase --- const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[]; for (const row of albumLinks) { diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md index e07d8be4a..7244339ff 100644 --- a/server/src/app-config/README.md +++ b/server/src/app-config/README.md @@ -108,7 +108,6 @@ The exemption list is enforced by the `no-restricted-syntax` ban on - `numberOr` (`Number(x) || d`) treats `"0"` and negative-invalid values oddly: `PORT=0` silently becomes 3001, `TREK_PLUGIN_RPC_BURST=-5` stays -5. -- `TRUST_PROXY=0` resolves to 1 hop (`parseInt || 1`). - APP_URL trailing-slash stripping differs per site (feeds strips one slash, notifications strips all). - `DEMO_ADMIN_EMAIL` defaults differ (demo-seed: admin@trek.app, demo-reset: diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index 58f15fb42..0d02eea01 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -63,6 +63,9 @@ function resolveDefaultLanguage(raw: string | undefined): string { } export function deriveHttp(raw: RawEnv) { + // env.schema.ts accepts 0 as a valid hop count, so `|| 1` would quietly turn + // "trust nothing" into "trust one hop" and let a forged X-Forwarded-For through. + const trustProxyHops = Number.parseInt(raw.TRUST_PROXY ?? '', 10); return { allowedOriginsRaw: raw.ALLOWED_ORIGINS, /** globalMiddleware CORS variant: trim + drop empty entries. */ @@ -70,7 +73,7 @@ export function deriveHttp(raw: RawEnv) { /** websocket variant: trim only — an empty entry stays (and can never match an Origin header). */ wsOrigins: csvList(raw.ALLOWED_ORIGINS), trustProxyRaw: raw.TRUST_PROXY, - trustProxy: Number.parseInt(raw.TRUST_PROXY ?? '') || 1, + trustProxy: Number.isFinite(trustProxyHops) ? trustProxyHops : 1, forceHttps: parseBool(raw.FORCE_HTTPS) === true, hstsIncludeSubdomains: parseBool(raw.HSTS_INCLUDE_SUBDOMAINS) === true, /** COOKIE_SECURE is tri-state: an explicit falsy value disables secure cookies; anything else means auto-detect. */ @@ -144,6 +147,8 @@ export function deriveDemo(raw: RawEnv) { /** Raw on purpose: demo-seed defaults to admin@trek.app, demo-reset to admin@nomad.app. */ adminEmailRaw: raw.DEMO_ADMIN_EMAIL, adminPass: raw.DEMO_ADMIN_PASS || 'admin12345', + /** False means the admin account is being created with the published default. */ + adminPassSet: !!raw.DEMO_ADMIN_PASS, }; } diff --git a/server/src/config.ts b/server/src/config.ts index a58090ed9..ea963ab0e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -36,10 +36,30 @@ if (_encryptionKey) { // Try the dedicated key file first (covers all installs after first start). try { _encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim(); - } catch { + } catch (readErr: unknown) { + if ((readErr as NodeJS.ErrnoException)?.code !== 'ENOENT') { + // The file is there, we just cannot read it (permissions, I/O error). + // Continuing would resolve a different key and overwrite this one below, + // which permanently orphans every stored secret. Refuse to start instead. + console.error( + 'FATAL: could not read', + encKeyFile, + '-', + readErr instanceof Error ? readErr.message : readErr, + ); + console.error('Fix the file permissions or set ENCRYPTION_KEY explicitly.'); + process.exit(1); + } // File not found — first start on an existing or fresh install. } + if (!_encryptionKey && fs.existsSync(encKeyFile)) { + // Present but empty: a truncated write, a full disk, a broken restore. + // Same reasoning as above. Regenerating here would silently replace the real key. + console.error('FATAL:', encKeyFile, 'is empty. Restore it from backup or set ENCRYPTION_KEY.'); + process.exit(1); + } + if (!_encryptionKey) { // One-time migration: existing install upgrading for the first time. // Use the JWT secret as the encryption key and immediately write it to diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 27d1d700a..3d382a65b 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2721,7 +2721,9 @@ function runMigrations(db: Database.Database): void { `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL);`, ); db.exec(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344');`); - db.exec(`INSERT INTO app_settings (key, value) VALUES ('app_version', '${readEnv().app.appVersion || '3.0.14'}')`); + db.prepare("INSERT INTO app_settings (key, value) VALUES ('app_version', ?)").run( + readEnv().app.appVersion || '3.0.14', + ); }, // trim leading/trailing whitespace from stored usernames and emails () => { @@ -2739,7 +2741,9 @@ function runMigrations(db: Database.Database): void { db.exec(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`); db.exec(`DROP TABLE schema_version`); db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`); - db.exec(`UPDATE app_settings SET value = '${readEnv().app.appVersion || '3.0.15'}' WHERE key = 'app_version'`); + db.prepare("UPDATE app_settings SET value = ? WHERE key = 'app_version'").run( + readEnv().app.appVersion || '3.0.15', + ); }, // Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential // clients to skip the browser consent flow entirely and obtain tokens directly @@ -3847,13 +3851,15 @@ function runMigrations(db: Database.Database): void { // means an expense split by receipt can never carry a written note, and // saving an expense any other way wipes whatever was typed in the budget // table. The receipt moves to its own column and note becomes text again. + // GLOB, not LIKE: LIKE is case-insensitive in SQLite, so a hand-written note + // starting "ticketjson:" would be chopped up and its text dropped. () => { const hasTicket = db.prepare("SELECT 1 FROM pragma_table_info('budget_items') WHERE name = 'ticket_json'").get(); if (!hasTicket) db.exec('ALTER TABLE budget_items ADD COLUMN ticket_json TEXT'); db.exec(` UPDATE budget_items SET ticket_json = substr(note, 12), note = NULL - WHERE note LIKE 'TICKETJSON:%' + WHERE note GLOB 'TICKETJSON:*' `); }, @@ -4091,15 +4097,24 @@ function runMigrations(db: Database.Database): void { try { const migration = migrations[i]; if (typeof migration === 'function') { - db.transaction(migration)(); + // The version bump has to commit together with the migration. Bumped + // afterwards, a crash in between leaves the schema advanced and the + // version stale, and the next boot replays a step that is not + // idempotent, exits 1, and turns one crash into a permanent boot loop. + db.transaction(() => { + migration(); + db.prepare('UPDATE schema_version SET version = ?').run(i + 1); + })(); } else { + // raw steps run outside a transaction on purpose (see PRAGMA + // foreign_keys above), so the bump stays a separate statement. migration.raw(); + db.prepare('UPDATE schema_version SET version = ?').run(i + 1); } } catch (err) { console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); process.exit(1); } - db.prepare('UPDATE schema_version SET version = ?').run(i + 1); } console.log(`[DB] Migrations complete — schema version ${migrations.length}`); } diff --git a/server/src/demo/demo-reset.ts b/server/src/demo/demo-reset.ts index 4eec2c538..d0a911cc4 100644 --- a/server/src/demo/demo-reset.ts +++ b/server/src/demo/demo-reset.ts @@ -3,9 +3,15 @@ import path from 'path'; import { readEnv } from '../app-config'; const dataDir = path.join(__dirname, '../../data'); -const dbPath = path.join(dataDir, 'travel.db'); const baselinePath = path.join(dataDir, 'travel-baseline.db'); +// Where the live DB actually is. database.ts honours TREK_DB_FILE, so hardcoding +// data/travel.db here would copy the baseline over an unrelated file and leave +// the database we just closed untouched. The open connection knows its own path. +function liveDbPath(db: { name: string }): string { + return db.name; +} + function resetDemoUser(): void { if (!fs.existsSync(baselinePath)) { console.log('[Demo Reset] No baseline found, skipping. Admin must save baseline first.'); @@ -13,6 +19,11 @@ function resetDemoUser(): void { } const { db, closeDb, reinitialize } = require('../db/database'); + const dbPath = liveDbPath(db); + if (dbPath === ':memory:') { + console.log('[Demo Reset] In-memory database, nothing to restore.'); + return; + } // Save admin's current credentials and API keys (these should survive the reset) // NOTE: different default than demo-seed (admin@trek.app) — pinned legacy quirk. @@ -98,6 +109,11 @@ function resetDemoUser(): void { function saveBaseline(): void { const { db } = require('../db/database'); + const dbPath = liveDbPath(db); + if (dbPath === ':memory:') { + console.log('[Demo] In-memory database, no baseline to save.'); + return; + } // Flush WAL so baseline file is self-contained try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} diff --git a/server/src/demo/demo-seed.ts b/server/src/demo/demo-seed.ts index f9cd553e4..c10291055 100644 --- a/server/src/demo/demo-seed.ts +++ b/server/src/demo/demo-seed.ts @@ -1,6 +1,9 @@ import bcrypt from 'bcryptjs'; import Database from 'better-sqlite3'; import { readEnv } from '../app-config'; +// Static like in demo-reset.job.ts: the module top is inert, everything that +// touches the database happens inside the functions. +import { saveBaseline, hasBaseline } from './demo-reset'; function seedDemoData(db: Database.Database): { adminId: number; demoId: number } { const ADMIN_USER = readEnv().demo.adminUser; @@ -12,6 +15,15 @@ function seedDemoData(db: Database.Database): { adminId: number; demoId: number // Create admin user if not exists let admin = db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL) as { id: number } | undefined; if (!admin) { + if (!readEnv().demo.adminPassSet) { + // The default is published in the docs and in this file, so an operator who + // never set DEMO_ADMIN_PASS is handing out an admin account. Say so loudly; + // changing the default would lock out every existing demo instance. + console.warn( + `[Demo] SECURITY: DEMO_ADMIN_PASS is not set. The admin account ${ADMIN_EMAIL} is being created ` + + 'with the public default password. Set DEMO_ADMIN_PASS before exposing this instance.', + ); + } const hash = bcrypt.hashSync(ADMIN_PASS, 10); const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(ADMIN_USER, ADMIN_EMAIL, hash, 'admin'); admin = { id: Number(r.lastInsertRowid) }; @@ -46,7 +58,6 @@ function seedDemoData(db: Database.Database): { adminId: number; demoId: number seedExampleTrips(db, admin.id, demo.id); // Auto-save baseline after first seed - const { saveBaseline, hasBaseline } = require('./demo-reset'); if (!hasBaseline()) { saveBaseline(); } diff --git a/server/src/nest/memories/photo-capture-backfill.service.ts b/server/src/nest/memories/photo-capture-backfill.service.ts index a41908878..b7894d6fa 100644 --- a/server/src/nest/memories/photo-capture-backfill.service.ts +++ b/server/src/nest/memories/photo-capture-backfill.service.ts @@ -48,7 +48,7 @@ export class PhotoCaptureBackfillService { continue; } - const info = await this.resolver.getPhotoInfo(id, userId); + const info = await this.resolver.getPhotoInfo(userId, id); if (!info.success) continue; this.photos.recordCaptureMetadata(id, { diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index 4637497c9..f53bcf1a7 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -1,4 +1,3 @@ -import { createRequire } from 'module'; import semver from 'semver'; import { readEnv } from '../app-config'; import { db } from '../db/database.js'; diff --git a/server/tests/unit/app-config/derive.test.ts b/server/tests/unit/app-config/derive.test.ts index 7886637ed..911f854fe 100644 --- a/server/tests/unit/app-config/derive.test.ts +++ b/server/tests/unit/app-config/derive.test.ts @@ -61,8 +61,10 @@ describe('deriveHttp', () => { expect(deriveHttp({}).wsOrigins).toBeNull(); }); - it('TRUST_PROXY: parseInt || 1 (globalMiddleware)', () => { + it('TRUST_PROXY: numeric hop count wins, 1 only as fallback (globalMiddleware)', () => { expect(deriveHttp({ TRUST_PROXY: '2' }).trustProxy).toBe(2); + // 0 is a valid hop count ("trust no proxy"), not a missing value. + expect(deriveHttp({ TRUST_PROXY: '0' }).trustProxy).toBe(0); expect(deriveHttp({ TRUST_PROXY: 'abc' }).trustProxy).toBe(1); expect(deriveHttp({}).trustProxy).toBe(1); }); @@ -125,6 +127,12 @@ describe('deriveDemo', () => { expect(deriveDemo({}).adminUser).toBe('admin'); expect(deriveDemo({}).adminPass).toBe('admin12345'); }); + + it('DEMO_ADMIN_PASS: adminPassSet tells the default apart from an explicit value', () => { + expect(deriveDemo({}).adminPassSet).toBe(false); + expect(deriveDemo({ DEMO_ADMIN_PASS: '' }).adminPassSet).toBe(false); + expect(deriveDemo({ DEMO_ADMIN_PASS: 'hunter22' }).adminPassSet).toBe(true); + }); }); describe('deriveOidc', () => { diff --git a/server/tests/unit/config.test.ts b/server/tests/unit/config.test.ts new file mode 100644 index 000000000..a38567387 --- /dev/null +++ b/server/tests/unit/config.test.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// config.ts resolves its key material at import time, so every case here has to +// reset the module registry and re-import it with the file system stubbed out. +const ENC_KEY_FILE = path.resolve(__dirname, '../../data/.encryption_key'); +const JWT_SECRET_FILE = path.resolve(__dirname, '../../data/.jwt_secret'); + +const enoent = (): NodeJS.ErrnoException => { + const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + return err; +}; + +describe('config — encryption key resolution', () => { + const realEnvKey = process.env.ENCRYPTION_KEY; + const realRead = fs.readFileSync; + + beforeEach(() => { + // The env var short-circuits the whole file-resolution chain under test. + delete process.env.ENCRYPTION_KEY; + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + process.env.ENCRYPTION_KEY = realEnvKey; + }); + + // Only the key file itself is faked; everything else keeps reading from disk, + // because app-config and its dependants load in the same import. + const stubKeyFileRead = (impl: () => string) => { + vi.spyOn(fs, 'readFileSync').mockImplementation(((file: fs.PathOrFileDescriptor, ...rest: unknown[]) => { + if (String(file) === ENC_KEY_FILE) return impl(); + if (String(file) === JWT_SECRET_FILE) throw enoent(); + return (realRead as (...args: unknown[]) => unknown)(file, ...rest); + }) as typeof fs.readFileSync); + }; + + it('CFGKEY-001: aborts boot when the key file cannot be read for a reason other than ENOENT', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + const write = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + stubKeyFileRead(() => { + const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + }); + + await expect(import('../../src/config')).rejects.toThrow('process.exit called'); + expect(exit).toHaveBeenCalledWith(1); + expect(write).not.toHaveBeenCalledWith(ENC_KEY_FILE, expect.anything(), expect.anything()); + }); + + it('CFGKEY-002: refuses to regenerate over an existing but empty key file', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + const write = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(fs, 'existsSync').mockImplementation(((file: fs.PathLike) => String(file) === ENC_KEY_FILE) as typeof fs.existsSync); + stubKeyFileRead(() => ' \n'); + + await expect(import('../../src/config')).rejects.toThrow('process.exit called'); + expect(exit).toHaveBeenCalledWith(1); + expect(write).not.toHaveBeenCalledWith(ENC_KEY_FILE, expect.anything(), expect.anything()); + }); + + it('CFGKEY-003: still auto-generates a key when nothing is on disk yet', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + // The key file is genuinely absent, so the empty-file guard must not fire. + vi.spyOn(fs, 'existsSync').mockImplementation(((file: fs.PathLike) => String(file) !== ENC_KEY_FILE) as typeof fs.existsSync); + stubKeyFileRead(() => { + throw enoent(); + }); + + const config = await import('../../src/config'); + expect(exit).not.toHaveBeenCalled(); + expect(config.ENCRYPTION_KEY).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/server/tests/unit/db/budget-ticket-json-migration.test.ts b/server/tests/unit/db/budget-ticket-json-migration.test.ts new file mode 100644 index 000000000..3b33821e0 --- /dev/null +++ b/server/tests/unit/db/budget-ticket-json-migration.test.ts @@ -0,0 +1,57 @@ +/** + * Boot migration: move the itemized receipt out of budget_items.note (#1658). + * + * The costs UI stored the receipt as a `TICKETJSON:` prefix on the note, so the + * migration lifts it into its own column and clears the note. The match has to + * be case-SENSITIVE: LIKE is not, which would swallow a hand-written note that + * happens to start with the same word. + */ +import { describe, it, expect } from 'vitest'; +import Database from 'better-sqlite3'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; + +function makeDbWithNotes(): Database.Database { + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA foreign_keys = ON'); + createTables(db); + // Minimal FK chain: user -> trip -> budget_items rows. + db.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'u', 'u@example.test', 'x')").run(); + db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run(); + db.prepare( + 'INSERT INTO budget_items (id, trip_id, name, note) VALUES (1, 1, ?, ?), (2, 1, ?, ?)', + ).run('Dinner', 'TICKETJSON:{"items":[]}', 'Museum', 'ticketjson: buy at the door'); + return db; +} + +describe('budget_items ticket_json migration', () => { + it('moves a real receipt into ticket_json and clears the note', () => { + const db = makeDbWithNotes(); + try { + runMigrations(db); + const row = db.prepare('SELECT note, ticket_json FROM budget_items WHERE id = 1').get() as { + note: string | null; + ticket_json: string | null; + }; + expect(row).toEqual({ note: null, ticket_json: '{"items":[]}' }); + } finally { + db.close(); + } + }); + + it('leaves a lowercase note alone (LIKE would have eaten it)', () => { + const db = makeDbWithNotes(); + try { + runMigrations(db); + const row = db.prepare('SELECT note, ticket_json FROM budget_items WHERE id = 2').get() as { + note: string | null; + ticket_json: string | null; + }; + expect(row).toEqual({ note: 'ticketjson: buy at the door', ticket_json: null }); + } finally { + db.close(); + } + }); +}); diff --git a/server/tests/unit/db/migrate-encryption-parity.test.ts b/server/tests/unit/db/migrate-encryption-parity.test.ts index 3f05bde1c..299946d07 100644 --- a/server/tests/unit/db/migrate-encryption-parity.test.ts +++ b/server/tests/unit/db/migrate-encryption-parity.test.ts @@ -12,6 +12,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -47,6 +48,40 @@ const storageBackendsSection = (() => { return script.slice(start, end); })(); +// The users loop only, bounded by its own marker and the next section. +const usersSection = (() => { + const start = script.indexOf('--- users:'); + expect(start).toBeGreaterThan(-1); + const end = script.indexOf('--- app_settings: storage.backends ---', start); + expect(end).toBeGreaterThan(start); + return script.slice(start, end); +})(); + +// The per-user settings loop only. +const settingsSection = (() => { + const start = script.indexOf('--- settings:'); + expect(start).toBeGreaterThan(-1); + const end = script.indexOf('--- plugins:', start); + expect(end).toBeGreaterThan(start); + return script.slice(start, end); +})(); + +// ENCRYPTED_SETTING_KEYS is module-private in settings.service.ts, so the +// canonical list is read off the source rather than imported. +const canonicalSettingKeys = (() => { + const source = fs.readFileSync(path.join(SERVER_ROOT, 'src', 'nest', 'settings', 'settings.service.ts'), 'utf8'); + const marker = 'const ENCRYPTED_SETTING_KEYS = new Set(['; + const start = source.indexOf(marker); + expect(start).toBeGreaterThan(-1); + const end = source.indexOf(']', start + marker.length); + return source + .slice(start + marker.length, end) + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((quoted) => quoted.slice(1, -1)); +})(); + describe('migrate-encryption.ts app_settings parity', () => { it('ROTPAR-001: rotates every instance-wide API key', () => { for (const name of INSTANCE_API_KEY_NAMES) { @@ -71,6 +106,36 @@ describe('migrate-encryption.ts app_settings parity', () => { expect(storageBackendsSection).toContain(`'${field}'`); } }); + + // airtrail.service.ts stores the key with maybe_encrypt_api_key, i.e. the same + // enc:v1: scheme; missed by a rotation the integration reads back as unset. + it('ROTPAR-006: rotates the per-user AirTrail key', () => { + const airtrail = fs.readFileSync( + path.join(SERVER_ROOT, 'src', 'nest', 'integrations', 'airtrail.service.ts'), + 'utf8', + ); + expect(airtrail).toContain('maybe_encrypt_api_key'); + expect(airtrail).toContain('airtrail_api_key'); + expect(usersSection).toContain("'airtrail_api_key'"); + }); + + it('ROTPAR-007: rotates every encrypted per-user setting key', () => { + expect(canonicalSettingKeys.length).toBeGreaterThan(0); + for (const key of canonicalSettingKeys) { + expect(settingsSection).toContain(`'${key}'`); + } + }); + + // Both plugin secret stores use the same scheme: OAuth tokens encrypted by + // plugin-oauth.service, and the config fields a manifest marked secret. + it('ROTPAR-008: rotates the plugin OAuth tokens and both config stores', () => { + expect(script).toContain('plugin_oauth_tokens'); + expect(script).toContain('access_token'); + expect(script).toContain('refresh_token'); + expect(script).toContain('plugin_settings_fields'); + expect(script).toContain("UPDATE plugins SET config = ?"); + expect(script).toContain('plugin_user_config'); + }); }); describe('migrate-encryption.ts storage.backends: malformed shape', () => { @@ -132,3 +197,117 @@ describe('migrate-encryption.ts storage.backends: malformed shape', () => { expect(output).toContain('app_settings.storage.backends: not an array — skipping'); }, 30000); }); + +// The same enc:v1: scheme the app and the script share, so a fixture can be +// written under the OLD key and read back under the NEW one. +const ENCRYPTED_PREFIX = 'enc:v1:'; +const apiKeyFor = (key: string) => crypto.createHash('sha256').update(`${key}:api_keys:v1`).digest(); + +function encryptWith(key: string, plain: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', apiKeyFor(key), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return `${ENCRYPTED_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64')}`; +} + +function decryptWith(key: string, value: string): string | null { + if (!value.startsWith(ENCRYPTED_PREFIX)) return null; + try { + const buf = Buffer.from(value.slice(ENCRYPTED_PREFIX.length), 'base64'); + const decipher = crypto.createDecipheriv('aes-256-gcm', apiKeyFor(key), buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} + +const BASE_SCHEMA = ` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + maps_api_key TEXT, unsplash_api_key TEXT, openweather_api_key TEXT, + immich_api_key TEXT, synology_password TEXT, synology_sid TEXT, + synology_did TEXT, airtrail_api_key TEXT, mfa_secret TEXT + ); + CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT); + CREATE TABLE trip_album_links (id INTEGER PRIMARY KEY, passphrase TEXT); + CREATE TABLE trek_photos (id INTEGER PRIMARY KEY, passphrase TEXT); + CREATE TABLE plugins (id TEXT PRIMARY KEY, config TEXT); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); + CREATE TABLE plugin_oauth_tokens ( + plugin_id TEXT, user_id INTEGER, access_token TEXT, refresh_token TEXT, + PRIMARY KEY (plugin_id, user_id) + ); + CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, config TEXT); +`; + +describe('migrate-encryption.ts rotates the stores the app writes', () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-migrate-encryption-plugins-')); + dbPath = path.join(tmpDir, 'travel.db'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('ROTPAR-009: re-encrypts AirTrail, llm_api_key, plugin OAuth tokens and plugin configs', () => { + const seed = new Database(dbPath); + seed.exec(BASE_SCHEMA); + seed.prepare('INSERT INTO users (id, airtrail_api_key) VALUES (1, ?)').run(encryptWith('old-key', 'at-token')); + seed.prepare('INSERT INTO settings (user_id, key, value) VALUES (1, ?, ?)').run('llm_api_key', encryptWith('old-key', 'sk-llm')); + seed.prepare('INSERT INTO plugins (id, config) VALUES (?, ?)').run( + 'koffi', + JSON.stringify({ api_key: encryptWith('old-key', 'plugin-secret'), server: 'https://h' }), + ); + seed.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, ?, ?, ?)').run('koffi', 'api_key', 'instance', 1); + seed.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, ?, ?, ?)').run('koffi', 'token', 'user', 1); + seed.prepare('INSERT INTO plugin_user_config (plugin_id, user_id, config) VALUES (?, ?, ?)').run( + 'koffi', + 1, + JSON.stringify({ token: encryptWith('old-key', 'user-secret') }), + ); + seed.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token) VALUES (?, ?, ?, ?)').run( + 'koffi', + 1, + encryptWith('old-key', 'access'), + encryptWith('old-key', 'refresh'), + ); + seed.close(); + + execFileSync(process.execPath, ['--import', 'tsx', 'scripts/migrate-encryption.ts'], { + cwd: SERVER_ROOT, + env: { ...process.env, DB_PATH: dbPath }, + input: ['old-key', 'new-key', 'yes', ''].join(String.fromCharCode(10)), + stdio: 'pipe', + encoding: 'utf8', + }); + + const after = new Database(dbPath, { readonly: true }); + const airtrail = (after.prepare('SELECT airtrail_api_key AS v FROM users WHERE id = 1').get() as { v: string }).v; + expect(decryptWith('new-key', airtrail)).toBe('at-token'); + + const llm = (after.prepare("SELECT value AS v FROM settings WHERE key = 'llm_api_key'").get() as { v: string }).v; + expect(decryptWith('new-key', llm)).toBe('sk-llm'); + + const tokens = after.prepare('SELECT access_token, refresh_token FROM plugin_oauth_tokens').get() as { + access_token: string; + refresh_token: string; + }; + expect(decryptWith('new-key', tokens.access_token)).toBe('access'); + expect(decryptWith('new-key', tokens.refresh_token)).toBe('refresh'); + + const pluginConfig = JSON.parse((after.prepare("SELECT config AS v FROM plugins WHERE id = 'koffi'").get() as { v: string }).v); + expect(decryptWith('new-key', pluginConfig.api_key)).toBe('plugin-secret'); + // A non-secret field is left exactly as it was. + expect(pluginConfig.server).toBe('https://h'); + + const userConfig = JSON.parse((after.prepare('SELECT config AS v FROM plugin_user_config').get() as { v: string }).v); + expect(decryptWith('new-key', userConfig.token)).toBe('user-secret'); + after.close(); + }, 30000); +}); diff --git a/server/tests/unit/db/migration-version-atomicity.test.ts b/server/tests/unit/db/migration-version-atomicity.test.ts new file mode 100644 index 000000000..919cce579 --- /dev/null +++ b/server/tests/unit/db/migration-version-atomicity.test.ts @@ -0,0 +1,55 @@ +/** + * The schema_version bump must commit with the migration that earned it. + * + * If it lands in a statement of its own, a crash between the two leaves the + * schema advanced and the version stale, and the next boot replays a step that + * is not idempotent (an INSERT INTO app_settings, say) and exits 1 forever. + */ +import { describe, it, expect, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; + +function migratedDb(): Database.Database { + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA foreign_keys = ON'); + createTables(db); + runMigrations(db); + return db; +} + +describe('migration version bump atomicity', () => { + it('MIGRATE-ATOMIC-001: writes schema_version inside the migration transaction', () => { + const db = migratedDb(); + try { + const { version } = db.prepare('SELECT version FROM schema_version LIMIT 1').get() as { version: number }; + // Rewind one slot so exactly the last migration replays. + db.prepare('UPDATE schema_version SET version = ?').run(version - 1); + + const realPrepare = db.prepare.bind(db); + const inTransaction: boolean[] = []; + vi.spyOn(db, 'prepare').mockImplementation(((sql: string) => { + const stmt = realPrepare(sql); + if (sql.includes('UPDATE schema_version SET version')) { + const realRun = stmt.run.bind(stmt); + stmt.run = ((...args: unknown[]) => { + inTransaction.push(db.inTransaction); + return realRun(...(args as never[])); + }) as typeof stmt.run; + } + return stmt; + }) as typeof db.prepare); + + runMigrations(db); + + expect(inTransaction).toEqual([true]); + const after = db.prepare('SELECT version FROM schema_version LIMIT 1').get() as { version: number }; + expect(after.version).toBe(version); + } finally { + vi.restoreAllMocks(); + db.close(); + } + }); +}); diff --git a/server/tests/unit/demo/demo-reset-path.test.ts b/server/tests/unit/demo/demo-reset-path.test.ts new file mode 100644 index 000000000..4c25fa501 --- /dev/null +++ b/server/tests/unit/demo/demo-reset-path.test.ts @@ -0,0 +1,40 @@ +/** + * Structural guard for demo-reset's DB path. + * + * demo-reset closes the live connection, copies the baseline over the database + * file and reopens. It used to compute that file itself as data/travel.db, which + * is wrong the moment TREK_DB_FILE moves the database elsewhere: the baseline + * lands on an unrelated file and the real database is never reset. The path has + * to come from the connection that database.ts actually opened. + * + * It is pinned on the source rather than executed because demo-reset resolves + * ../db/database through a runtime require() (the module is a boot-time + * singleton), which vitest's module runner cannot resolve. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const source = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'src', 'demo', 'demo-reset.ts'), 'utf8'); + +describe('demo-reset DB path', () => { + it('DEMORESET-001: never hardcodes the default travel.db path', () => { + expect(source).not.toContain("'travel.db'"); + }); + + it('DEMORESET-002: takes the path from the open connection', () => { + expect(source).toContain('db.name'); + // Both entry points, not just one of them. + const uses = source.match(/liveDbPath\(db\)/g) ?? []; + expect(uses.length).toBe(2); + }); + + it('DEMORESET-003: keeps the baseline next to the data directory', () => { + expect(source).toContain("path.join(dataDir, 'travel-baseline.db')"); + }); + + it('DEMORESET-004: bails out on an in-memory database instead of copying files', () => { + const guards = source.match(/=== ':memory:'/g) ?? []; + expect(guards.length).toBe(2); + }); +}); diff --git a/server/tests/unit/demo/demo-seed.test.ts b/server/tests/unit/demo/demo-seed.test.ts new file mode 100644 index 000000000..82db3bcd5 --- /dev/null +++ b/server/tests/unit/demo/demo-seed.test.ts @@ -0,0 +1,64 @@ +/** + * Demo mode boot seeding. + * + * DEMO_MODE=true creates a role=admin account on first boot. When + * DEMO_ADMIN_PASS is unset that account gets the password published in + * demo-seed itself, so the seeder has to say so out loud. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createTestDb } from '../../helpers/test-db'; + +// Baseline handling is demo-reset's job and touches the file system. +vi.mock('../../../src/demo/demo-reset', () => ({ + saveBaseline: vi.fn(), + hasBaseline: vi.fn(() => true), + resetDemoUser: vi.fn(), +})); + +import { seedDemoData } from '../../../src/demo/demo-seed'; + +describe('demo seeding', () => { + let db: Database.Database; + const realPass = process.env.DEMO_ADMIN_PASS; + + beforeEach(() => { + db = createTestDb(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + db.close(); + if (realPass === undefined) delete process.env.DEMO_ADMIN_PASS; + else process.env.DEMO_ADMIN_PASS = realPass; + }); + + it('DEMOSEED-001: warns when the admin account is created with the default password', () => { + delete process.env.DEMO_ADMIN_PASS; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + seedDemoData(db); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set')); + }); + + it('DEMOSEED-002: stays quiet when the operator set DEMO_ADMIN_PASS', () => { + process.env.DEMO_ADMIN_PASS = 'a-real-password'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + seedDemoData(db); + + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set')); + }); + + it('DEMOSEED-003: does not warn again once the admin account exists', () => { + delete process.env.DEMO_ADMIN_PASS; + seedDemoData(db); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + seedDemoData(db); + + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set')); + }); +}); diff --git a/server/tests/unit/nest/photo-capture-backfill.service.test.ts b/server/tests/unit/nest/photo-capture-backfill.service.test.ts index b389a1acc..d1970adfb 100644 --- a/server/tests/unit/nest/photo-capture-backfill.service.test.ts +++ b/server/tests/unit/nest/photo-capture-backfill.service.test.ts @@ -23,7 +23,10 @@ type Row = { id: number; provider?: string; file_path?: string | null; taken_at? function build(rows: Row[], info: Record) { const recordCaptureMetadata = vi.fn(); - const getPhotoInfo = vi.fn(async (id: number) => + // Keyed on the SECOND argument on purpose: the resolver's signature is + // (userId, photoId), and a mock that keys on the first one passes just as + // happily when the two are swapped at the call site. + const getPhotoInfo = vi.fn(async (_userId: number, id: number) => info[id] ? { success: true, data: info[id] } : { success: false, error: 'nope', status: 404 }, ); const photos = { @@ -92,6 +95,14 @@ describe('PhotoCaptureBackfillService', () => { }); }); + it('CAPTURE-006a: passes the acting user and the photo id in the order the resolver declares', async () => { + const { svc, getPhotoInfo } = build([{ id: 7 }], { 7: { takenAt: '2026-03-15T10:20:00Z' } }); + + await svc.run([7], 42); + + expect(getPhotoInfo).toHaveBeenCalledWith(42, 7); + }); + it('CAPTURE-006: an empty batch touches nothing', () => { const { svc, getPhotoInfo } = build([], {}); svc.schedule([], 1); From eadb3703bc797be4210eb811d20466c9db42376d Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 09:48:23 +0200 Subject: [PATCH 10/25] fix(shared,tests): bound the book contract and close the gaps that hid all this Book page setup and element geometry were unbounded numbers, so a shared book could be saved with zero, negative or astronomical dimensions and every consumer downstream - the canvas, the PDF export - had to cope. Day-note caps had drifted from the limits their own doc comment states, and icon had none. On the test side: the path filter meant a PR touching only the root lockfile ran no jobs at all, which is exactly the change class most likely to break a native binding; the canAccessTrip mock is hand-copied into 72 files and had already drifted from the real query; the path-traversal test asserted only status !== 200, so a 500 from a broken guard passed it; and one unit test shelled out to a real kitinerary binary if the host happened to have one. --- .github/workflows/test.yml | 7 +++ server/src/nest/day-notes/day-notes.mcp.ts | 4 +- server/tests/helpers/mcp-test-controllers.ts | 5 +- server/tests/helpers/test-db.ts | 20 ++++++-- server/tests/integration/security.test.ts | 7 ++- .../kitinerary-extractor.service.test.ts | 16 ++++++- shared/src/book/book.schema.spec.ts | 45 ++++++++++++++++++ shared/src/book/book.schema.ts | 47 +++++++++++++++---- shared/src/day/day.schema.spec.ts | 10 ++++ shared/src/day/day.schema.ts | 12 +++-- shared/src/realtime/events.schema.ts | 2 +- 11 files changed, 149 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46c76f682..a19cbbc3b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,13 @@ on: - 'client/**' - 'shared/**' - 'plugin-sdk/**' + # npm workspaces hoist every dependency into the single root lockfile, so + # a dependency bump touches none of the four directories above — and a + # native binding like better-sqlite3 is exactly what needs the suite. + - 'package.json' + - 'package-lock.json' + - 'Dockerfile' + - 'docker-compose.yml' - '.github/workflows/test.yml' jobs: diff --git a/server/src/nest/day-notes/day-notes.mcp.ts b/server/src/nest/day-notes/day-notes.mcp.ts index 71a2782ef..a0ba802f7 100644 --- a/server/src/nest/day-notes/day-notes.mcp.ts +++ b/server/src/nest/day-notes/day-notes.mcp.ts @@ -40,7 +40,7 @@ export class DayNotesMcp { dayId: z.number().int().positive(), text: z.string().min(1).max(500), time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'), - icon: z.string().optional().describe('Emoji icon for the note'), + icon: z.string().max(64).optional().describe('Emoji icon for the note'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, @@ -67,7 +67,7 @@ export class DayNotesMcp { noteId: z.number().int().positive(), text: z.string().min(1).max(500).optional(), time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'), - icon: z.string().optional().describe('Emoji icon for the note'), + icon: z.string().max(64).optional().describe('Emoji icon for the note'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index f0d6285a9..1d27a5f4a 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -119,8 +119,9 @@ export function createMcpTestRegistry(): McpRegistry { const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService)); // The last three were previously omitted, which left them `undefined` at // runtime — silently fine while nothing called them, a TypeError the moment - // the journey skeleton hooks landed on the place write paths. tsconfig only - // includes `src`, so nothing here is typechecked; pass them for real. + // the journey skeleton hooks landed on the place write paths. tsconfig.tests.json + // covers `tests` now and CI runs it (npm run typecheck:tests), so a missed + // dependency fails the build — pass them for real regardless of the gate. const placesService = new PlacesService( dbService, permissionsService, realtimeService, mapsService, queryHelpersService, new UnsplashService(dbService, new RuntimeEnvService(), generalStorage), diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index cabce9375..cd283ed10 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -217,6 +217,20 @@ export function resetTestDb(db: Database.Database): void { seedDefaults(db); } +/** + * Byte-for-byte the statement in src/db/database.ts. + * + * Exported because the same query is copied into ~90 test files, and every one + * of those copies had dropped `t.currency` — so the budget domain, which reads + * exactly that column off the access row, was only ever exercising its 'EUR' + * fallback. Import this instead of retyping it. + */ +export const CAN_ACCESS_TRIP_SQL = ` + SELECT t.id, t.user_id, t.currency 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) + `; + /** * Returns the mock factory for vi.mock('../../src/db/database', ...). * The returned object mirrors the shape of database.ts exports. @@ -266,11 +280,7 @@ export function buildDbMock(testDb: Database.Database) { }; }, canAccessTrip: (tripId: number | string, userId: number) => { - return testDb.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); + return testDb.prepare(CAN_ACCESS_TRIP_SQL).get(userId, tripId, userId); }, isOwner: (tripId: number | string, userId: number) => { return !!testDb.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); diff --git a/server/tests/integration/security.test.ts b/server/tests/integration/security.test.ts index ceac29db1..9e721c1c6 100644 --- a/server/tests/integration/security.test.ts +++ b/server/tests/integration/security.test.ts @@ -221,7 +221,10 @@ describe('File download path traversal', () => { const res = await request(app) .get(`/api/trips/${trip.id}/files/${fileId}/download`) .set(authHeader(user.id)); - // path.basename() strips traversal in the download controller; normalized file does not exist in uploads - expect(res.status).not.toBe(200); + // path.basename() strips traversal in the download controller; the normalized + // name does not exist in uploads, so the answer is the same 404 a missing file + // gets. Pinned exactly: a 500 from a thrown guard would also be "not 200". + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'File not found' }); }); }); diff --git a/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts b/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts index 4780f1f08..1ebf54290 100644 --- a/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts +++ b/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts @@ -8,10 +8,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; * at all, and every branch that resolves it is a filesystem lookup that behaves * differently on the three platforms TREK ships to. */ -const { existsSync, readdirSync, readEnv } = vi.hoisted(() => ({ +const { existsSync, readdirSync, readEnv, execSync, execFile } = vi.hoisted(() => ({ existsSync: vi.fn(), readdirSync: vi.fn(), readEnv: vi.fn(), + execSync: vi.fn(), + // A plain function, because the service promisifies it at module load. + execFile: vi.fn(), })); vi.mock('node:fs', () => ({ @@ -20,6 +23,10 @@ vi.mock('node:fs', () => ({ writeFileSync: vi.fn(), unlinkSync: vi.fn(), })); +// The last branch of the probe shells out. Unmocked, the suite spawns a real +// process on every machine and comes back green-or-red depending on whether the +// developer happens to have KItinerary installed. +vi.mock('node:child_process', () => ({ execSync, execFile })); vi.mock('../../../../src/app-config', () => ({ readEnv })); import { KitineraryExtractorService } from '../../../../src/nest/booking-import/kitinerary-extractor.service'; @@ -35,6 +42,7 @@ beforeEach(() => { vi.clearAllMocks(); existsSync.mockReturnValue(false); readdirSync.mockReturnValue([]); + execSync.mockImplementation(() => { throw new Error('command not found'); }); }); describe('KitineraryExtractorService binary probe', () => { @@ -70,4 +78,10 @@ describe('KitineraryExtractorService binary probe', () => { it('KIT-EXT-006: extracting without a binary fails loudly', async () => { await expect(boot().extract(Buffer.from(''), 'x.pdf')).rejects.toThrow('not available'); }); + + it('KIT-EXT-007: falls back to the binary on PATH when nothing is on disk', () => { + execSync.mockReturnValue(Buffer.from('')); + expect(boot().isAvailable()).toBe(true); + expect(execSync).toHaveBeenCalledWith('kitinerary-extractor --version', expect.objectContaining({ timeout: 3000 })); + }); }); diff --git a/shared/src/book/book.schema.spec.ts b/shared/src/book/book.schema.spec.ts index 1f29fc7b7..936b48aa8 100644 --- a/shared/src/book/book.schema.spec.ts +++ b/shared/src/book/book.schema.spec.ts @@ -3,6 +3,7 @@ import { bookBadgeElementSchema, bookIconElementSchema, bookSpreadSchema, + bookStatsElementSchema, bookTextElementSchema, normalizeBookDocument, } from './book.schema'; @@ -136,6 +137,22 @@ describe('a badge code', () => { }); }); +describe('the figures a stats element carries', () => { + const stats = (over: Record = {}) => ({ id: 's1', kind: 'stats', frame, ...over }); + + it('keeps the metrics it is drawn from', () => { + const parsed = bookStatsElementSchema.parse(stats({ values: { distance: 1420000, days: 12 } })); + expect(parsed.values).toEqual({ distance: 1420000, days: 12 }); + }); + + it('drops a key no reader could ever index by, without failing the element', () => { + // Dropped rather than rejected: a rejected stats element is stripped as + // unreadable, which loses the page the figures were on. + const parsed = bookStatsElementSchema.parse(stats({ values: { distance: 12, sneaky: 1, ['x'.repeat(5000)]: 2 } })); + expect(parsed.values).toEqual({ distance: 12 }); + }); +}); + describe('normalizeBookDocument', () => { it('keeps the nine elements it can read when the tenth is from another version', () => { const nine = Array.from({ length: 9 }, (_, i) => text({ id: `t${i + 1}`, text: `line ${i + 1}` })); @@ -172,6 +189,34 @@ describe('normalizeBookDocument', () => { expect(out.page.pageWidth).toBe(210); }); + it('falls back to the preset for a page dimension nothing could be printed at', () => { + // A page block that fails takes the whole document with it, and the empty + // book that comes back is what the server then writes down. So each of + // these degrades to its default and the spread survives. + for (const bad of [0, -5, 1e12]) { + const out = normalizeBookDocument({ + ...doc([text({ id: 'a' })]), + page: { preset: 'square-210', pageWidth: bad, pageHeight: 210, bleed: 3, safe: 5 }, + }); + expect(out.page.pageWidth).toBe(210); + expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['a']); + } + + const negativeBleed = normalizeBookDocument({ + ...doc([text({ id: 'a' })]), + page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: -3, safe: 5 }, + }); + expect(negativeBleed.page.bleed).toBe(3); + expect(negativeBleed.spreads[0]!.elements).toHaveLength(1); + }); + + it('drops an element parked a kilometre off the spread rather than the book', () => { + const out = normalizeBookDocument(doc([text({ id: 'a' }), text({ id: 'far', frame: { x: 1e9, y: 0, w: 60, h: 40 } })])); + + expect(out.spreads[0]!.elements.map((el) => el.id)).toEqual(['a']); + expect(out.title).toBe('Iceland, end to end'); + }); + it('hands back an empty book for something that is not a document at all', () => { for (const junk of [undefined, null, 'a book, honestly', 42, [], { spreads: 'nope' }]) { const out = normalizeBookDocument(junk); diff --git a/shared/src/book/book.schema.ts b/shared/src/book/book.schema.ts index f1c603c24..3a2e23610 100644 --- a/shared/src/book/book.schema.ts +++ b/shared/src/book/book.schema.ts @@ -25,8 +25,16 @@ import { z } from 'zod'; * edits the text in Studio, which sets `overridden` and stops the sync. */ -/** Two decimals of a millimetre. Anything finer is below print resolution. */ -const mm = z.number().finite().transform(v => Math.round(v * 100) / 100); +/** + * Two decimals of a millimetre. Anything finer is below print resolution. + * + * Bounded at ten metres in either direction, which is four orders of magnitude + * past the largest book anyone binds and still leaves room for an element + * parked well off the spread. Without a bound a stored `1e9` is a legal + * millimetre, and the editor and the PDF renderer both try to draw a sheet a + * thousand kilometres wide. + */ +const mm = z.number().finite().min(-10000).max(10000).transform(v => Math.round(v * 100) / 100); const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb'); @@ -491,8 +499,21 @@ export const bookStatsElementSchema = z.object({ layout: z.enum(['grid', 'row', 'column']).default('grid'), showIcons: z.boolean().default(true), units: bookUnitsSchema.default('metric'), - /** Metric to value. Distance is metres, everything else a plain count. */ - values: z.record(z.string(), z.number().finite()).default({}), + /** + * Metric to value. Distance is metres, everything else a plain count. + * + * Anything that is not a metric is dropped rather than rejected: both readers + * only ever index by a `BOOK_METRICS` member, so a stray key is payload that + * gets persisted and re-served to every viewer without ever being drawn. + * Filtered instead of typed as `z.record(z.enum(BOOK_METRICS), …)`, because on + * zod 4 an enum key makes the record exhaustive — every metric would become + * required and every stats element written so far would fail, then be thrown + * away as unreadable. + */ + values: z.record(z.string(), z.number().finite()).default({}) + .transform(v => Object.fromEntries( + Object.entries(v).filter(([k]) => (BOOK_METRICS as readonly string[]).includes(k)), + )), }); export const bookCountriesElementSchema = z.object({ @@ -729,11 +750,19 @@ export type BookPageNumbers = z.infer; export const bookPageSetupSchema = z.object({ preset: z.enum(['square-210', 'square-300', 'a4-landscape', 'a4-portrait', 'a5-landscape', 'custom']).default('square-210'), - /** A single page. A double spread is drawn twice this wide. */ - pageWidth: mm.default(210), - pageHeight: mm.default(210), - bleed: mm.default(3), - safe: mm.default(5), + /** + * A single page. A double spread is drawn twice this wide. + * + * These four fall back rather than fail, like `version` below. The salvage + * pass only strips unreadable *elements*, so a page block the contract + * cannot read takes the whole book down to an empty one — and that empty + * book is what the server writes back. A nonsense dimension degrading to the + * preset is the smaller loss by a long way. + */ + pageWidth: mm.refine(v => v > 0, 'page width must be positive').catch(210).default(210), + pageHeight: mm.refine(v => v > 0, 'page height must be positive').catch(210).default(210), + bleed: mm.refine(v => v >= 0, 'bleed cannot be negative').catch(3).default(3), + safe: mm.refine(v => v >= 0, 'safe margin cannot be negative').catch(5).default(5), pageNumbers: bookPageNumbersSchema.default(() => bookPageNumbersSchema.parse({})), }); export type BookPageSetup = z.infer; diff --git a/shared/src/day/day.schema.spec.ts b/shared/src/day/day.schema.spec.ts index f5b9c9568..1aad1b307 100644 --- a/shared/src/day/day.schema.spec.ts +++ b/shared/src/day/day.schema.spec.ts @@ -35,6 +35,15 @@ describe('dayNoteCreateRequestSchema', () => { it('accepts null time/icon (moveDayNote re-sends the nullable entity fields)', () => { expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: null, icon: null }).success).toBe(true); }); + + it('takes an icon as a label and not as a payload', () => { + // Both shapes the field is written in: a lucide export name from NOTE_ICONS + // and a plain emoji, plus the longest ZWJ sequence anyone types. + expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', icon: 'ParkingSquare' }).success).toBe(true); + expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', icon: '👩‍👩‍👧‍👦' }).success).toBe(true); + expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', icon: 'x'.repeat(64) }).success).toBe(true); + expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', icon: 'x'.repeat(65) }).success).toBe(false); + }); }); describe('dayNoteUpdateRequestSchema', () => { @@ -42,6 +51,7 @@ describe('dayNoteUpdateRequestSchema', () => { expect(dayNoteUpdateRequestSchema.safeParse({}).success).toBe(true); expect(dayNoteUpdateRequestSchema.safeParse({ icon: '🍽️' }).success).toBe(true); expect(dayNoteUpdateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false); + expect(dayNoteUpdateRequestSchema.safeParse({ icon: 'x'.repeat(65) }).success).toBe(false); }); it('accepts an explicit null time (clears the label)', () => { diff --git a/shared/src/day/day.schema.ts b/shared/src/day/day.schema.ts index 5ebdb6f51..1836fb0b8 100644 --- a/shared/src/day/day.schema.ts +++ b/shared/src/day/day.schema.ts @@ -9,8 +9,10 @@ import { z } from 'zod'; * Trip-scoped, both gated by the 'day_edit' permission. Served by the Nest * DaysController / DayNotesController (server/src/nest/days/). Day rows (with * their assignments) are wide and DB-derived, so list responses stay open. Day - * notes cap text at 500 and time at 150 chars (the legacy - * validateStringLengths middleware) — reproduced in the controller. + * note writes are capped field by field: text 500, time 2000, icon 64, color 9. + * Time is wider than the legacy validateStringLengths middleware's 150 because + * the raw-body route it replaced already accepted more, and narrowing it now + * would reject payloads today's client is allowed to send. */ /** @@ -86,7 +88,9 @@ export type DayTransportRequest = z.infer; export const dayNoteCreateRequestSchema = z.object({ text: z.string().min(1).max(500), time: z.string().max(2000).nullable().optional(), - icon: z.string().nullable().optional(), + // A lucide name from NOTE_ICONS or a single emoji; 64 leaves room for a ZWJ + // sequence with skin-tone modifiers and still keeps the column a label. + icon: z.string().max(64).nullable().optional(), /** One of NOTE_COLORS, or null for the neutral card (#1629). */ color: z.string().max(9).nullable().optional(), sort_order: z.number().optional(), @@ -96,7 +100,7 @@ export type DayNoteCreateRequest = z.infer; export const dayNoteUpdateRequestSchema = z.object({ text: z.string().max(500).optional(), time: z.string().max(2000).nullable().optional(), - icon: z.string().nullable().optional(), + icon: z.string().max(64).nullable().optional(), color: z.string().max(9).nullable().optional(), sort_order: z.number().optional(), }); diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index e89aa8371..84903d12b 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -352,7 +352,7 @@ export const TREK_WS_EVENTS = { }, } as const satisfies Record; -/** Every registered event name (65 trip-scoped + 29 user-scoped). */ +/** Every registered event name. The registry above is the count; this derives from it. */ export type TrekWsEventName = keyof typeof TREK_WS_EVENTS; /** Names broadcast to a trip room (`{ type, tripId, ...payload }` envelope). */ From 47a4276e847baf916ef9bd47544081f6781049b6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 10:07:24 +0200 Subject: [PATCH 11/25] fix(client): make the offline core survive a logout and stop clobbering itself logout() unregistered every sync trigger, and the only place that registers them is App's mount effect - which never runs again, because logout navigates through the router rather than reloading. Log out, log back in, go offline, edit something: it queues and nothing ever flushes it. No online listener, no visibilitychange, no 30s heartbeat, no WS reconnect hook. Register on auth success instead; registerSyncTriggers is already idempotent. The background sync loops only checked the auth gate on entry. Logout swaps the Dexie handle to the shared anonymous database, so a trip loop or a blob download still in flight afterwards wrote the logged-out account's itinerary and documents into it - the database that survives logout and greets the next person on a shared device. Check the gate per iteration. And the remote place:updated applier replaced the whole embedded place, while the local reducer right next to it preserves the assignment's own times. Same event, two answers, and the remote one wipes a scheduled 09:00-10:30 off the itinerary when someone else renames the place. One merge helper now, used by both, so they cannot drift again. The test that should have caught it seeded null times and asserted only the name. Also: moving a day note deleted before it created, and restored the note only locally when the create failed; t() interpolation was corrupted by a '$' in a parameter value; a 429 upload was treated as permanently failed; and the geolocation watch was started inside a state updater. --- client/src/api/oauthScopes.test.ts | 27 ++++++++++++-- client/src/hooks/useGeolocation.test.ts | 21 +++++++++++ client/src/hooks/useGeolocation.ts | 37 +++++++++++++------ client/src/i18n/TranslationContext.tsx | 6 ++- client/src/store/authStore.ts | 6 ++- client/src/store/slices/dayNotesSlice.ts | 11 +++++- client/src/store/slices/placesSlice.ts | 10 ++++- client/src/store/slices/remoteEventHandler.ts | 3 +- client/src/sync/tripSyncManager.ts | 21 ++++++++++- client/src/utils/uploadQueue.test.ts | 16 ++++++++ client/src/utils/uploadQueue.ts | 4 ++ client/tests/setup.ts | 12 +++++- .../tests/unit/mswUnhandledRequests.test.ts | 10 +++++ .../unit/remoteEventHandler/places.test.ts | 23 ++++++++++++ client/tests/unit/sync/syncTriggers.test.ts | 4 ++ 15 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 client/tests/unit/mswUnhandledRequests.test.ts diff --git a/client/src/api/oauthScopes.test.ts b/client/src/api/oauthScopes.test.ts index 443c30b64..81712c63f 100644 --- a/client/src/api/oauthScopes.test.ts +++ b/client/src/api/oauthScopes.test.ts @@ -1,7 +1,20 @@ -// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010 +// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-011 import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes' +// The consent page mirrors the server's scope list by hand, so read the server +// file and compare. Parsing keeps this a client-only test (no server import). +function readServerScopes(): string[] { + const here = dirname(fileURLToPath(import.meta.url)) + const src = readFileSync(resolve(here, '../../../server/src/mcp/scopes.ts'), 'utf8') + const block = src.slice(src.indexOf('export const SCOPES = {')) + const body = block.slice(0, block.indexOf('} as const;')) + return [...body.matchAll(/^\s*[A-Z_]+:\s*'([a-z]+:[a-z]+)'/gm)].map(m => m[1]) +} + describe('SCOPE_GROUPS', () => { it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => { const expected = [ @@ -34,8 +47,16 @@ describe('SCOPE_GROUPS', () => { }) describe('ALL_SCOPES', () => { - it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => { - expect(ALL_SCOPES).toHaveLength(29) + it('FE-OAUTH-SCOPES-003: contains exactly as many scopes as the server defines', () => { + expect(ALL_SCOPES).toHaveLength(readServerScopes().length) + }) + + it('FE-OAUTH-SCOPES-011: matches server/src/mcp/scopes.ts exactly', () => { + const serverScopes = readServerScopes() + // Guards the parse itself: a moved file or a reformatted list must fail + // here rather than quietly compare two empty sets. + expect(serverScopes.length).toBeGreaterThan(0) + expect(new Set(ALL_SCOPES)).toEqual(new Set(serverScopes)) }) it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => { diff --git a/client/src/hooks/useGeolocation.test.ts b/client/src/hooks/useGeolocation.test.ts index 0f3bba137..1f9bc118a 100644 --- a/client/src/hooks/useGeolocation.test.ts +++ b/client/src/hooks/useGeolocation.test.ts @@ -1,4 +1,5 @@ // FE-HOOK-GEO-001 to FE-HOOK-GEO-023 +import { StrictMode } from 'react'; import { act, renderHook } from '@testing-library/react'; import { GeoOnceError, getCurrentPositionOnce, useGeolocation } from './useGeolocation'; @@ -221,6 +222,26 @@ describe('useGeolocation', () => { expect(clearWatch).toHaveBeenCalledWith(7); }); + it('FE-HOOK-GEO-015b: the updater form sees the freshest mode and starts one watch', async () => { + // The side effects run outside the state updater (React may invoke an + // updater more than once per commit, and startWatch awaits the iOS + // orientation prompt before it records the watch id), so the previous mode + // comes from a ref that setMode keeps in step within a batch. + const requestPermission = vi.fn().mockResolvedValue('granted'); + vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission })); + + const { result } = renderHook(() => useGeolocation(), { wrapper: StrictMode }); + + await act(async () => { + result.current.setMode('show'); + result.current.setMode(prev => (prev === 'show' ? 'follow' : 'off')); + await Promise.resolve(); + }); + + expect(result.current.mode).toBe('follow'); + expect(watchPosition).toHaveBeenCalledTimes(1); + }); + it('FE-HOOK-GEO-016: asks iOS for orientation permission and proceeds either way', async () => { const requestPermission = vi.fn().mockResolvedValue('denied'); vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission })); diff --git a/client/src/hooks/useGeolocation.ts b/client/src/hooks/useGeolocation.ts index e6f991082..565699208 100644 --- a/client/src/hooks/useGeolocation.ts +++ b/client/src/hooks/useGeolocation.ts @@ -93,12 +93,20 @@ export function getCurrentPositionOnce( export function useGeolocation(): UseGeolocationReturn { const [position, setPosition] = useState(null) const [mode, setModeState] = useState('off') + // What setMode's updater form reads instead of a state updater's argument: + // React may run an updater more than once per commit, and subscribing from + // inside one would leak a watch (see setMode below). + const modeRef = useRef(mode) + modeRef.current = mode const [error, setError] = useState(null) const watchIdRef = useRef(null) + // True between the start of startWatch and the watchPosition call it awaits. + const startingRef = useRef(false) const orientationHandlerRef = useRef<((e: DeviceOrientationEvent) => void) | null>(null) const headingRef = useRef(null) const stopWatch = useCallback(() => { + startingRef.current = false if (watchIdRef.current !== null) { try { navigator.geolocation.clearWatch(watchIdRef.current) } catch { /* noop */ } watchIdRef.current = null @@ -116,6 +124,11 @@ export function useGeolocation(): UseGeolocationReturn { setError('Geolocation is not supported in this browser') return false } + // Already watching, or still waiting on the iOS prompt below: a second + // subscription would overwrite watchIdRef and leave the first one running + // with nobody left holding its id. + if (startingRef.current || watchIdRef.current !== null) return true + startingRef.current = true setError(null) // iOS: ask for orientation permission up front; on Android and desktop @@ -157,6 +170,7 @@ export function useGeolocation(): UseGeolocationReturn { window.addEventListener('deviceorientationabsolute', onOrientation as EventListener) window.addEventListener('deviceorientation', onOrientation as EventListener) + startingRef.current = false watchIdRef.current = navigator.geolocation.watchPosition( (pos) => { setPosition({ @@ -189,17 +203,18 @@ export function useGeolocation(): UseGeolocationReturn { }, [stopWatch]) const setMode = useCallback((m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => { - setModeState(prev => { - const next = typeof m === 'function' ? m(prev) : m - if (next === 'off') { - stopWatch() - setPosition(null) - } else if (watchIdRef.current === null) { - // started externally but no watch yet — start it - startWatch() - } - return next - }) + const next = typeof m === 'function' ? m(modeRef.current) : m + // Kept in step here as well, so two setMode calls batched into one commit + // still see each other's result. + modeRef.current = next + setModeState(next) + if (next === 'off') { + stopWatch() + setPosition(null) + } else if (watchIdRef.current === null) { + // started externally but no watch yet — start it + void startWatch() + } }, [startWatch, stopWatch]) const cycleMode = useCallback(async () => { diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index d320f3e1d..efacfb112 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -133,7 +133,9 @@ export function TranslationProvider({ children }: TranslationProviderProps) { let val: string = (strings[key] ?? en[key] ?? key) as string if (params) { Object.entries(params).forEach(([k, v]) => { - val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)) + // Function replacement: a value carrying `$&` or `$1` (a trip named + // "A$&B") would otherwise be expanded as a replacement pattern. + val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), () => String(v)) }) } return val @@ -145,7 +147,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) { Object.entries(params).forEach(([k, v]) => { // Escape BEFORE substitution so a user-controlled value with `<` or // `&` cannot break out of the surrounding template's markup. - val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), escapeHtml(String(v))) + val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), () => escapeHtml(String(v))) }) } // Then re-sanitise the fully-built string: even if a translator ships a diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 4355d424c..224f67fa1 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -7,7 +7,7 @@ import { getApiErrorMessage } from '../types' import { tripSyncManager } from '../sync/tripSyncManager' import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb' import { setAuthed } from '../sync/authGate' -import { unregisterSyncTriggers } from '../sync/syncTriggers' +import { registerSyncTriggers, unregisterSyncTriggers } from '../sync/syncTriggers' import { useSystemNoticeStore } from './systemNoticeStore.js' import { clearAppearanceSnapshot } from '../theme/applyAppearance' import { clearAllPluginSessions } from './pluginStore' @@ -95,6 +95,10 @@ async function onAuthSuccess(userId: number): Promise { } catch (err) { console.error('[auth] failed to open user-scoped offline DB', err) } + // logout() tears the triggers down, and App's mount effect never runs again in + // an SPA session, so a second login in the same tab would leave the mutation + // queue without a flush trigger. Re-registering is a no-op while they are up. + registerSyncTriggers() } export const useAuthStore = create()( diff --git a/client/src/store/slices/dayNotesSlice.ts b/client/src/store/slices/dayNotesSlice.ts index 1ca9578cd..7d795acc3 100644 --- a/client/src/store/slices/dayNotesSlice.ts +++ b/client/src/store/slices/dayNotesSlice.ts @@ -112,7 +112,9 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice })) try { - await dayNotesApi.delete(tripId, fromDayId, noteId) + // There is no atomic move on the server, so the destructive half goes last: + // if the create were second and failed, the note would already be gone for + // good and the rollback below would only fake it back into the store. // Every field the note carries, not just the ones it had when this was // written: a move is a delete plus a create, so anything omitted here is // silently dropped — which is how a coloured note lost its colour on the @@ -120,6 +122,13 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice const result = await dayNotesApi.create(tripId, toDayId, { text: note.text, time: note.time, icon: note.icon, color: note.color ?? null, sort_order, }) + try { + await dayNotesApi.delete(tripId, fromDayId, noteId) + } catch (delErr: unknown) { + // The source survived, so drop the copy rather than leave a duplicate behind. + await dayNotesApi.delete(tripId, toDayId, result.note.id).catch(() => {}) + throw delErr + } set(s => ({ dayNotes: { ...s.dayNotes, diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index fe7069883..8dc2c8b88 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -19,6 +19,14 @@ export interface PlacesSlice { updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial) => Promise } +/** Swap the pool place embedded in an assignment while keeping the assignment's + * own times: the day view shows COALESCE(assignment_time, place_time), so + * overwriting them with the pool row would wipe a per-stop schedule. Exported + * because the WS applier in remoteEventHandler has to merge the same way. */ +export function mergeAssignmentPlace(a: Assignment, place: Place): Assignment { + return { ...a, place: { ...place, place_time: a.place.place_time, end_time: a.place.end_time } } +} + /** Replace a place in the pool and in every day-assignment that embeds it, * preserving the assignment's own times (mirrors updatePlace's reconciliation). */ function applyUpdatedPlace(set: SetState, placeId: number, place: Place): void { @@ -28,7 +36,7 @@ function applyUpdatedPlace(set: SetState, placeId: number, place: Place): void { for (const [dayId, items] of Object.entries(state.assignments)) { if (items.some((a: Assignment) => a.place?.id === placeId)) { updatedAssignments[dayId] = items.map((a: Assignment) => - a.place?.id === placeId ? { ...a, place: { ...place, place_time: a.place.place_time, end_time: a.place.end_time } } : a + a.place?.id === placeId ? mergeAssignmentPlace(a, place) : a ) changed = true } diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index 1615ce179..d7877ee61 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -4,6 +4,7 @@ import type { TripStoreState } from '../tripStore' import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetItemMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types' import { offlineDb } from '../../db/offlineDb' import { useAuthStore } from '../authStore' +import { mergeAssignmentPlace } from './placesSlice' type SetState = StoreApi['setState'] type GetState = StoreApi['getState'] @@ -212,7 +213,7 @@ export const STATE_APPLIERS: Partial> assignments: Object.fromEntries( Object.entries(state.assignments).map(([dayId, items]) => [ dayId, - items.map(a => a.place?.id === (payload.place as Place).id ? { ...a, place: payload.place as Place } : a) + items.map(a => a.place?.id === (payload.place as Place).id ? mergeAssignmentPlace(a, payload.place as Place) : a) ]) ), }), diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 0d5b3a15a..d38acd18e 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -52,8 +52,15 @@ interface TripBundle { // ── Helpers ─────────────────────────────────────────────────────────────────── +// Trip dates are plain local calendar dates, so format ours the same way. +// toISOString() would hand back the UTC day and drop an ongoing trip a day +// early west of UTC (and keep a finished one a day too long east of it). +function ymd(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + function todayStr(): string { - return new Date().toISOString().slice(0, 10) + return ymd(new Date()) } /** @@ -76,7 +83,7 @@ function isStale(trip: Trip): boolean { if (!trip.end_date) return false const cutoff = new Date() cutoff.setDate(cutoff.getDate() - 7) - return trip.end_date < cutoff.toISOString().slice(0, 10) + return trip.end_date < ymd(cutoff) } function isPhoto(file: TripFile): boolean { @@ -123,6 +130,9 @@ async function cacheFilesForTrip(tripId: number, files: TripFile[]): Promise { expect(res.failed).toEqual([f]) }) + it('FE-W4UPQ-007b: retries a 429 rather than dropping the file', async () => { + let calls = 0 + const uploadOne = vi.fn(async (f: File) => { + calls++ + // A proxy in front of a self-hosted instance rate-limits the batch. + if (calls === 1) throw { response: { status: 429 } } + return [f.name] + }) + + const res = await uploadFilesResilient([file('a.txt')], uploadOne, { retries: 1 }) + + expect(calls).toBe(2) + expect(res.succeeded).toEqual(['a.txt']) + expect(res.failed).toEqual([]) + }) + it('FE-W4UPQ-008: retries a 5xx up to the limit then gives up', async () => { const uploadOne = vi.fn(async () => { throw { response: { status: 502 } } }) const f = file('a.txt') diff --git a/client/src/utils/uploadQueue.ts b/client/src/utils/uploadQueue.ts index de2f92b8e..721cc2d77 100644 --- a/client/src/utils/uploadQueue.ts +++ b/client/src/utils/uploadQueue.ts @@ -22,6 +22,10 @@ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) function isRetryable(err: unknown): boolean { if (err && typeof err === 'object' && 'response' in err) { const status = (err as { response?: { status?: number } }).response?.status + // 408/425/429: timeout / too-early / rate-limited. TREK's own upload routes + // do not send them, but a proxy in front of a self-hosted instance does, and + // dropping the file for one of those loses a photo we could have re-sent. + if (status === 408 || status === 425 || status === 429) return true if (status !== undefined && status >= 400 && status < 500) return false } return true diff --git a/client/tests/setup.ts b/client/tests/setup.ts index dcbe38674..d0375bea4 100644 --- a/client/tests/setup.ts +++ b/client/tests/setup.ts @@ -21,8 +21,16 @@ vi.mock('../src/api/websocket', () => ({ removeListener: vi.fn(), })); -// MSW lifecycle -beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })); +// MSW lifecycle. A cross-origin request nobody mocked is the dangerous kind: 'warn' +// lets it through, so the runner really talks to frankfurter or a tile server and settles +// a promise after the test environment is gone (see handlers/external.ts). Those fail now. +// An unhandled same-origin call stays a warning: that is a missing handler, not egress. +beforeAll(() => server.listen({ + onUnhandledRequest: (request, print) => { + if (new URL(request.url).origin === location.origin) print.warning(); + else print.error(); + }, +})); afterEach(() => { server.resetHandlers(); cleanup(); diff --git a/client/tests/unit/mswUnhandledRequests.test.ts b/client/tests/unit/mswUnhandledRequests.test.ts new file mode 100644 index 000000000..76eabe011 --- /dev/null +++ b/client/tests/unit/mswUnhandledRequests.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; + +// Guards the lifecycle option in tests/setup.ts: a cross-origin call nobody mocked must +// fail here rather than actually leaving the runner (see msw/handlers/external.ts for the +// FX widget that once did, and answered after its test environment was gone). +describe('msw unhandled requests', () => { + it('FE-MSW-EGRESS-001: an unmocked cross-origin request is refused', async () => { + await expect(fetch('https://unmocked.example/rates')).rejects.toThrow(); + }); +}); diff --git a/client/tests/unit/remoteEventHandler/places.test.ts b/client/tests/unit/remoteEventHandler/places.test.ts index 58d5c9ea9..9fb244581 100644 --- a/client/tests/unit/remoteEventHandler/places.test.ts +++ b/client/tests/unit/remoteEventHandler/places.test.ts @@ -51,6 +51,29 @@ describe('remoteEventHandler > places', () => { expect(assignments['10'][0].place?.name).toBe('Cascaded Update'); }); + it('FE-WSEVT-PLACE-004b: place:updated keeps the assignment its own times', () => { + // The day view renders COALESCE(assignment_time, place_time), so the copy + // embedded in an assignment carries the per-stop schedule. A broadcast about + // the pool row must not overwrite it (the local reducer merges the same way). + const place = buildPlace({ id: 1, name: 'Original', place_time: '08:00', end_time: '09:00' }); + const assignment = buildAssignment({ + id: 100, + day_id: 10, + place: { ...place, place_time: '14:00', end_time: '15:30' }, + }); + useTripStore.setState({ places: [place], assignments: { '10': [assignment] } }); + + useTripStore.getState().handleRemoteEvent({ + type: 'place:updated', + place: buildPlace({ id: 1, name: 'Renamed', place_time: '08:00', end_time: '09:00' }), + }); + + const embedded = useTripStore.getState().assignments['10'][0].place!; + expect(embedded.name).toBe('Renamed'); + expect(embedded.place_time).toBe('14:00'); + expect(embedded.end_time).toBe('15:30'); + }); + it('FE-WSEVT-PLACE-005: place:deleted removes place from places array', () => { seedData(); useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 1 }); diff --git a/client/tests/unit/sync/syncTriggers.test.ts b/client/tests/unit/sync/syncTriggers.test.ts index fb1aa6eb3..b2826fca2 100644 --- a/client/tests/unit/sync/syncTriggers.test.ts +++ b/client/tests/unit/sync/syncTriggers.test.ts @@ -82,6 +82,10 @@ describe('syncTriggers', () => { expect(flush).toHaveBeenCalled(); expect(syncAll).toHaveBeenCalled(); expect(hydrate).toHaveBeenCalledWith('7'); + // The order is the point: re-seeding Dexie before the queue drains would + // overwrite edits that have not been sent yet. + expect(flush.mock.invocationCallOrder[0]).toBeLessThan(syncAll.mock.invocationCallOrder[0]); + expect(flush.mock.invocationCallOrder[0]).toBeLessThan(hydrate.mock.invocationCallOrder[0]); }); it('online event retries the settings load when it has not yet succeeded (#1618)', async () => { From 61d4fd4d641f94e8e091185a3c691c7f07bd51eb Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 10:07:43 +0200 Subject: [PATCH 12/25] fix(planner): scheme-check the booking URL, and stop the serial routing calls A booking's url is rendered as a plain href to every other member of the trip and nothing ever checked its scheme, so a collaborator could store javascript: there. The place website next to it has been going through safeHttpUrl for exactly this reason, with a comment saying stored values predate the server check - this is the surface that was left. Both ends now: safeHttpUrl at the render site, and the scheme in the shared contract so nothing new gets stored, additively so existing rows still load. The per-leg routing rewrite turned one multi-waypoint request per run into one serial request per leg. A twelve-stop day is eleven round trips to a public OSRM in sequence before a single connector appears, and toggling km/mi does it all again. Group consecutive legs that resolve to the same mode into one request and fire the groups concurrently, keeping leg order on the join. Transport reordering wrote to the store and persisted with a bare catch, so a failed PUT left the sidebar showing an order the server never got; it reconciles now. Plus the smaller ones: the OSM menu_url rendered unchecked, expand/collapse-all persisting to a different storage than the day list reads, the debounced address search with no ordering guard, the accommodation save chain with no error path, and window.__trek_map holding a destroyed map. --- client/src/components/Map/MapViewGL.test.tsx | 9 ++ client/src/components/Map/MapViewGL.tsx | 5 ++ .../components/Planner/AddressInput.test.tsx | 24 +++++- .../src/components/Planner/AddressInput.tsx | 14 +++- .../Planner/DayDetailPanel.test.tsx | 28 ++++++- .../src/components/Planner/DayDetailPanel.tsx | 69 +++++++++------- .../Planner/DayPlanSidebar.test.tsx | 71 ++++++++++++++++ .../src/components/Planner/DayPlanSidebar.tsx | 82 +++++++++++++++---- .../Planner/DayPlanSidebarToolbar.test.tsx | 8 +- .../Planner/DayPlanSidebarToolbar.tsx | 4 +- .../Planner/PlaceDetailsColumn.test.tsx | 14 ++++ .../components/Planner/PlaceDetailsColumn.tsx | 8 +- .../Planner/ReservationsPanel.test.tsx | 19 ++++- .../components/Planner/ReservationsPanel.tsx | 12 ++- client/src/hooks/useRouteCalculation.ts | 19 +++-- .../hooks/useRouteCalculation.test.ts | 42 ++++++++++ .../reservation/reservation.schema.spec.ts | 18 ++++ shared/src/reservation/reservation.schema.ts | 24 +++++- 18 files changed, 407 insertions(+), 63 deletions(-) diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index eceae5a2f..0b9de7f13 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -1696,6 +1696,15 @@ describe('MapViewGL', () => { expect(glMap.remove).toHaveBeenCalled() }) + it('FE-COMP-MAPVIEWGL-069: unmounting lets go of the window debug handle', async () => { + const { unmount } = render() + await act(async () => {}) + expect((window as any).__trek_map).toBe(glMap) + unmount() + // Otherwise every style switch keeps its torn-down map reachable from window. + expect((window as any).__trek_map).toBeUndefined() + }) + it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill', async () => { const onViewportChange = vi.fn() render() diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index eeac19e91..a2f3ab69a 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -930,6 +930,11 @@ export function MapViewGL({ } try { map.remove() } catch { /* noop */ } mapRef.current = null + // Drop the debug handle too, or a style switch keeps every torn-down map + // (canvas and sources included) alive for the rest of the page's life. The + // identity check leaves a freshly built map alone if this cleanup runs late. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((window as any).__trek_map === map) delete (window as any).__trek_map setMapReady(false) } }, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only diff --git a/client/src/components/Planner/AddressInput.test.tsx b/client/src/components/Planner/AddressInput.test.tsx index e52d93c7c..617305d7f 100644 --- a/client/src/components/Planner/AddressInput.test.tsx +++ b/client/src/components/Planner/AddressInput.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-ADDRIN-001 to FE-PLANNER-ADDRIN-014 +// FE-PLANNER-ADDRIN-001 to FE-PLANNER-ADDRIN-015 import { useState } from 'react'; import { delay, http, HttpResponse } from 'msw'; import userEvent from '@testing-library/user-event'; @@ -206,6 +206,28 @@ describe('AddressInput', () => { expect(input).toHaveValue('Sacher'); }); + it('FE-PLANNER-ADDRIN-015: a slow earlier search cannot overwrite the newer results', async () => { + // The debounce only cancels the timer; a request already on its way keeps + // going, and this one answers last. + server.use(http.post('/api/maps/search', async ({ request }) => { + const { query } = await request.json() as { query: string }; + if (query === 'Sacher') { await delay(600); return HttpResponse.json({ places: [HOTEL] }); } + return HttpResponse.json({ places: [CAFE] }); + })); + + render(); + const input = screen.getByPlaceholderText('Street and number'); + fireEvent.change(input, { target: { value: 'Sacher' } }); + await settle(350); + fireEvent.change(input, { target: { value: 'Cafe Central' } }); + + expect(await screen.findByText('Cafe Central')).toBeInTheDocument(); + // Now let the first request land. + await settle(500); + expect(screen.getByText('Cafe Central')).toBeInTheDocument(); + expect(screen.queryByText('Hotel Sacher')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-ADDRIN-014: a mousedown outside closes the list and a failing search empties it', async () => { const user = userEvent.setup(); server.use(searchRoute([HOTEL])); diff --git a/client/src/components/Planner/AddressInput.tsx b/client/src/components/Planner/AddressInput.tsx index 07c51f0f3..50ed98b32 100644 --- a/client/src/components/Planner/AddressInput.tsx +++ b/client/src/components/Planner/AddressInput.tsx @@ -22,6 +22,9 @@ export default function AddressInput({ value, onChange, placeholder, className } const [loading, setLoading] = useState(false) const wrapRef = useRef(null) const debounceRef = useRef | null>(null) + // Clearing the timer does nothing to a request that is already out, and + // mapsApi.search takes no signal, so results are matched by ticket instead. + const reqIdRef = useRef(0) useEffect(() => { const handler = (e: MouseEvent) => { @@ -38,23 +41,28 @@ export default function AddressInput({ value, onChange, placeholder, className } const search = (text: string) => { if (debounceRef.current) clearTimeout(debounceRef.current) const trimmed = text.trim() - if (trimmed.length < 3) { setResults([]); setLoading(false); return } + // Same reason as in pick(): a request for the longer text may still land. + if (trimmed.length < 3) { reqIdRef.current++; setResults([]); setLoading(false); return } debounceRef.current = setTimeout(async () => { + const myReq = ++reqIdRef.current setLoading(true) try { const data = await mapsApi.search(trimmed, locale) + if (myReq !== reqIdRef.current) return setResults(data.places || []) setHighlight(-1) } catch { - setResults([]) + if (myReq === reqIdRef.current) setResults([]) } finally { - setLoading(false) + if (myReq === reqIdRef.current) setLoading(false) } }, 320) } const pick = (r: any) => { if (debounceRef.current) clearTimeout(debounceRef.current) + // A response still on its way must not repopulate the list behind the pick. + reqIdRef.current++ onChange(r.address || r.name || '') setOpen(false) setResults([]) diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index d1f84e3e4..f8ee30de8 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-079 +// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-080 import React from 'react'; import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -342,6 +342,32 @@ describe('DayDetailPanel', () => { expect(buttons.length).toBeGreaterThanOrEqual(2); }); + it('FE-PLANNER-DAYDETAIL-080: a failing accommodation edit keeps the picker open and says so', async () => { + const addToast = vi.fn(); + window.__addToast = addToast; + server.use( + http.get('/api/trips/1/accommodations', () => + HttpResponse.json({ + accommodations: [{ + id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris', + start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null, + }], + }), + ), + http.put('/api/trips/1/accommodations/1', () => HttpResponse.json({ error: 'Stay overlaps' }, { status: 400 })), + ); + render(); + await screen.findByText('Grand Hotel'); + // The pencil beside the stay opens the picker in edit mode. + await userEvent.click(document.querySelector('.lucide-pencil')!.closest('button')!); + const picker = await waitFor(() => document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement); + await userEvent.click(within(picker).getByText('Save')); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Stay overlaps', 'error', undefined)); + // The picker stays put so the entered values are not lost. + expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument(); + }); + it('FE-PLANNER-DAYDETAIL-022: accommodation edit/remove buttons hidden when canEditDays=false', async () => { // Use regular user + restrict day_edit to admin only const regularUser = buildUser({ id: 999, role: 'user' }); diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index e430ad24b..39eabef90 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -13,6 +13,7 @@ import { useTripStore } from '../../store/tripStore' import CustomSelect from '../shared/CustomSelect' import CustomTimePicker from '../shared/CustomTimePicker' import { useSettingsStore } from '../../store/settingsStore' +import { useToast } from '../shared/Toast' import { getLocaleForLanguage, useTranslation } from '../../i18n' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' import { isDayInAccommodationRange } from '../../utils/dayOrder' @@ -492,6 +493,44 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD days, locale, hotelForm, setHotelForm, categories, hotelCategoryFilter, setHotelCategoryFilter, places, handleSelectPlace, accommodation, tripId, day, setAccommodations, setDayAccommodations, setAccommodation, handleSaveAccommodation, onAccommodationChange }: any) { + const toast = useToast() + // Saving the picker. The edit branch stays here rather than in useDayDetail + // because it also refreshes the panel's own accommodation state; it has to say + // what went wrong when the write fails, otherwise the picker just sits there + // with the Save button doing nothing. + const saveHotelPicker = async () => { + if (showHotelPicker !== 'edit' || !accommodation) { + await handleSaveAccommodation() + return + } + try { + await accommodationsApi.update(tripId, accommodation.id, { + place_id: hotelForm.place_id, + start_day_id: hotelDayRange.start, + end_day_id: hotelDayRange.end, + check_in: hotelForm.check_in || null, + check_in_end: hotelForm.check_in_end || null, + check_out: hotelForm.check_out || null, + confirmation: hotelForm.confirmation || null, + }) + setShowHotelPicker(false) + setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) + // Reload + const d = await accommodationsApi.list(tripId) + const all = d.accommodations || [] + setAccommodations(all) + setDayAccommodations(all.filter(a => + day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false + )) + const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false) + setAccommodation(acc || null) + onAccommodationChange?.() + } catch (err: unknown) { + const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error + toast.error(message || t('common.unknownError')) + } + } + return ( <> {/* Hotel Picker Popup — portal to body to escape transform stacking context */} @@ -635,35 +674,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD - diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx index b9ffcf8eb..626a97794 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx @@ -1,4 +1,4 @@ -// FE-W4BGT-001 to FE-W4BGT-020 +// FE-W4BGT-001 to FE-W4BGT-022 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { screen, act, waitFor } from '@testing-library/react' import { render, fireEvent } from '../../../tests/helpers/render' @@ -185,6 +185,34 @@ describe('BackgroundTasksWidget — rehydrate', () => { }) }) +describe('BackgroundTasksWidget — poll backstop', () => { + it('FE-W4BGT-021: a job the server drops mid-poll ends as an error instead of spinning on', async () => { + vi.useFakeTimers() + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus) + .mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never) + .mockRejectedValue({ response: { status: 404 } }) + + render() + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + + expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('error') + }) + + it('FE-W4BGT-022: a transient poll failure leaves the job running', async () => { + vi.useFakeTimers() + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus) + .mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never) + .mockRejectedValue({ response: { status: 500 } }) + + render() + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + + expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('running') + }) +}) + describe('BackgroundTasksWidget — AI retry', () => { const withFiles = () => task({ items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })], diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx index 9d3a5b79f..6101c4e81 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx @@ -111,11 +111,15 @@ export default function BackgroundTasksWidget() { else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error') else setProgress(task.id, task.tripId, s.done, s.total) }) - .catch(() => {}) + .catch((err: { response?: { status?: number } }) => { + // The server 404s a job once it has expired (or after a restart). Nothing is + // ever coming, so end the card instead of spinning and re-polling forever. + if (err?.response?.status === 404) setError(task.id, task.tripId, t('reservations.import.error')) + }) } }, 5000) return () => clearInterval(iv) - }, [tasks, setProgress, setDone, setError]) + }, [tasks, setProgress, setDone, setError, t]) if (tasks.length === 0) return null diff --git a/client/src/components/Files/FileManagerImageLightbox.test.tsx b/client/src/components/Files/FileManagerImageLightbox.test.tsx index f0c470860..6721cfc61 100644 --- a/client/src/components/Files/FileManagerImageLightbox.test.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.test.tsx @@ -1,7 +1,7 @@ -// FE-W4LBX-001 to FE-W4LBX-019 +// FE-W4LBX-001 to FE-W4LBX-020 import { describe, it, expect, vi, beforeEach } from 'vitest' import type { TripFile } from '../../types' -import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render' +import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render' const getAuthUrl = vi.fn(async (url: string, _kind: string) => `${url}?token=abc`) const openFile = vi.fn(async (_url: string, _name: string) => {}) @@ -222,6 +222,23 @@ describe('ImageLightbox', () => { expect(first).not.toHaveBeenCalled() }) + it('FE-W4LBX-020: a mint that lands after the gallery moved on does not paint', async () => { + const pending: Record void> = {} + getAuthUrl.mockImplementation((url: string) => new Promise(resolve => { pending[url] = resolve })) + const { container } = render( {}} />) + await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download')) + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/b.jpg', 'download')) + // The token for the image we already left resolves last. + await act(async () => { + pending['/f/b.jpg']('/f/b.jpg?token=b') + pending['/f/a.jpg']('/f/a.jpg?token=a') + }) + + expect(container.querySelector('img[alt="b.jpg"]')).toHaveAttribute('src', '/f/b.jpg?token=b') + }) + it('FE-W4LBX-019: the strip mints thumbnail tokens only for thumbs in view', async () => { // Every thumbnail costs its own one-shot token, so an off-screen thumb must // stay quiet. The default observer stub never intersects. diff --git a/client/src/components/Files/FileManagerImageLightbox.tsx b/client/src/components/Files/FileManagerImageLightbox.tsx index 0b2b53911..aa497850b 100644 --- a/client/src/components/Files/FileManagerImageLightbox.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.tsx @@ -27,7 +27,12 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro setImgSrc('') // Images use a one-shot signed URL; a video must use the plain same-origin // URL (cookie auth) so its many Range requests all authenticate (#823). - if (file && !isVideo(file.mime_type)) getAuthUrl(file.url, 'download').then(setImgSrc) + if (!file || isVideo(file.mime_type)) return + // Arrowing through the gallery leaves several mints in flight; only the one for + // the file still on screen may paint. + let current = true + getAuthUrl(file.url, 'download').then(u => { if (current) setImgSrc(u) }).catch(() => {}) + return () => { current = false } }, [file?.url, file?.mime_type]) const goPrev = () => setIndex(i => Math.max(0, i - 1)) diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 6d9b990ad..58e5133a1 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -300,6 +300,18 @@ describe('downloadTripPDF', () => { expect(iframe!.srcdoc).toContain('cover.jpg') }) + it('FE-COMP-TRIPPDF-023: a cover url cannot close url() and add a second declaration', async () => { + // cover_image is a free string on the write path, and the style attribute is decoded + // before the CSS is parsed, so the quote and the paren have to be encoded. + const hostile = "http://host/a.jpg');background-image:url('http://elsewhere/leak.jpg" + await downloadTripPDF({ ...richArgs, trip: { ...richArgs.trip, cover_image: hostile } as any }) + const iframe = getIframe() + const style = /
{ server.use( http.get('/api/trips/:id/accommodations', () => diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 179c96461..7e531f4d8 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -81,6 +81,13 @@ function escHtml(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } +// The style attribute is decoded back into raw quotes before the CSS is parsed, so +// escHtml alone cannot stop a cover_image from closing the url() and appending its own +// declaration. Percent-encoding is transparent to the fetch. +function cssUrl(url) { + return String(url).replace(/["'()\\\s]/g, c => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')) +} + function absUrl(url) { if (!url) return null if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url @@ -703,7 +710,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
- ${coverImg ? `
` : ''} + ${coverImg ? `
` : ''}
diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx index ebc2edbb2..dfe17fc3b 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx @@ -229,6 +229,33 @@ describe('KategorieGruppe — bulk actions', () => { await waitFor(() => expect(puts).toEqual([1])) }) + it('FE-W5CAT-011a: Check All sends the items together, not one round trip after another', async () => { + const started: number[] = [] + let release = () => {} + const gate = new Promise(resolve => { release = resolve }) + server.use( + http.put('/api/trips/1/packing/:itemId', async ({ params }) => { + started.push(Number(params.itemId)) + // The first item only answers once the second one has been sent, so a + // serialised loop would never get its second request out. + if (started.length === 1) await gate + return HttpResponse.json({ item: buildPackingItem({ id: Number(params.itemId) }) }) + }), + ) + const { container } = setup({ + items: [ + buildPackingItem({ id: 1, name: 'Tent', checked: 0 }), + buildPackingItem({ id: 2, name: 'Rope', checked: 0 }), + ], + }) + openCategoryMenu(container) + + fireEvent.click(screen.getByText('Check All')) + + await waitFor(() => expect(started).toHaveLength(2)) + release() + }) + it('FE-W5CAT-012: a failing PUT during Check All is reported by the store, once', async () => { server.use(http.put('/api/trips/1/packing/:itemId', () => new HttpResponse(null, { status: 500 }))) seedStore(useTripStore, { packingItems: [buildPackingItem({ id: 1, name: 'Tent', checked: 0 })] }) diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx index 5579f0955..8c2bad168 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx @@ -99,16 +99,13 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen } // togglePackingItem rolls its own optimistic update back and reports the - // failure itself, so the bulk actions just drive it item by item. + // failure itself, so the bulk actions just drive it item by item. They go out + // together: serialised, a long list costs one round trip per item. const handleCheckAll = async () => { - for (const item of Array.from(items)) { - if (!item.checked) await togglePackingItem(tripId, item.id, true) - } + await Promise.all(items.filter(i => !i.checked).map(i => togglePackingItem(tripId, i.id, true))) } const handleUncheckAll = async () => { - for (const item of Array.from(items)) { - if (item.checked) await togglePackingItem(tripId, item.id, false) - } + await Promise.all(items.filter(i => i.checked).map(i => togglePackingItem(tripId, i.id, false))) } const handleDeleteAll = async () => { await onDeleteAll(items) diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx index 34b28c50a..151484793 100644 --- a/client/src/components/Plugins/PluginFrame.test.tsx +++ b/client/src/components/Plugins/PluginFrame.test.tsx @@ -1,4 +1,4 @@ -// FE-PLUGINS-FRAME-001 to 046 +// FE-PLUGINS-FRAME-001 to 061 import { render, cleanup, waitFor, fireEvent, screen, act } from '@testing-library/react'; import PluginFrame from './PluginFrame'; import { usePluginStore } from '../../store/pluginStore'; @@ -340,12 +340,12 @@ describe('PluginFrame', () => { function mount(granted: boolean) { grant(granted); - const { container, unmount } = render(); - const iframe = container.querySelector('iframe')!; + const view = render(); + const iframe = view.container.querySelector('iframe')!; const posted: Array> = []; (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); - return { iframe, posted, unmount }; + return { ...view, iframe, posted }; } it('FE-PLUGINS-FRAME-015: refuses an ungranted plugin without touching the browser API', () => { @@ -381,6 +381,33 @@ describe('PluginFrame', () => { expect(geo.clearWatch).toHaveBeenCalledWith(7); }); + it('FE-PLUGINS-FRAME-060: a host settings change re-bridges without dropping the watch', () => { + let tick: ((p: unknown) => void) | null = null; + geo.watchPosition.mockImplementation((ok: (p: unknown) => void) => { tick = ok; return 7; }); + const { iframe, posted, rerender } = mount(true); + act(() => { fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g20', action: 'watch' }); }); + + // Any settings write re-runs the bridge effect; the watch must survive it. + host.settings = { ...DEFAULT_SETTINGS, distance_unit: 'imperial' }; + act(() => { rerender(); }); + + expect(geo.clearWatch).not.toHaveBeenCalled(); + const before = posted.length; + act(() => tick!({ coords: { latitude: 5, longitude: 6, accuracy: 5, heading: null, speed: null }, timestamp: 3 })); + expect(posted.length).toBe(before + 1); + }); + + it('FE-PLUGINS-FRAME-061: a frame that navigates itself has its watch released', () => { + const { iframe } = mount(true); + act(() => { fireEvent.load(iframe); }); + act(() => { fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g21', action: 'watch' }); }); + + // Second load = the frame navigated away, so nothing receives the stream any more. + act(() => { fireEvent.load(iframe); }); + + expect(geo.clearWatch).toHaveBeenCalledWith(7); + }); + it('FE-PLUGINS-FRAME-018: clear stops the watch on request', () => { const { iframe, posted } = mount(true); fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g4', action: 'watch' }); diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index f773d3731..e65af4390 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -650,8 +650,6 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d window.removeEventListener('message', onMessage) themeObserver.disconnect() if (wsForward) removeListener(wsForward) - // The frame is going away (or re-bridging) — never leave a live GPS watch behind. - if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null } } }, [pluginId, tripId, fill, navigate, postFrame, buildContext, userId]) @@ -664,6 +662,11 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d setHeight(null) confirmReqRef.current = null setConfirmReq(null) + // Only a real teardown may kill the watch: the bridge above re-runs on any + // settings, locale or breakpoint change, and nothing would re-arm it. + return () => { + if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null } + } }, [pluginId]) const answerConfirm = (confirmed: boolean) => { @@ -689,7 +692,14 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d // Deliver the context as soon as the document is parsed (the plugin sets up its // message listener during parse), closing the trek:ready race so the theme is // right on first paint. A 2nd load is a self-navigation — don't bridge to it. - onLoad={() => { setLoadFailed(false); loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }} + onLoad={() => { + setLoadFailed(false) + loadsRef.current += 1 + if (loadsRef.current === 1) postFrame(buildContext()) + // A self-navigated frame is no longer bridged, so its watch is only + // burning battery for positions nobody receives. + else if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null } + }} onError={() => setLoadFailed(true)} sandbox="allow-scripts allow-forms" referrerPolicy="no-referrer" diff --git a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx index 5e9f1d766..63822b13d 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx @@ -298,6 +298,18 @@ describe('ModalRenderer', () => { expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy(); }); + it('FE-SN-MODAL-057: a body param is inserted literally, not as a replacement pattern', async () => { + const notice = makeNotice({ + bodyKey: 'Version {version} is here', + bodyParams: { version: '4.0.0 ($&)' }, + }); + render(); + + await flushGraceDelay(); + + expect(screen.getByText('Version 4.0.0 ($&) is here')).toBeTruthy(); + }); + it('FE-SN-MODAL-008: empty notices renders nothing', () => { const { container } = render(); expect(container.firstChild).toBeNull(); diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index e2fce3284..74bd3d98b 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -617,13 +617,17 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) { type NoticeState = ReturnType; +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Build the NoticeContent props for a given notice + pager slot index. function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps { const { t, isDark, canPage, notices, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S; const rawBody = t(n.bodyKey); + // The key is escaped into the pattern and the value goes in through a function, so a + // value carrying $&, $1 or $` is inserted as typed instead of being expanded. const body = n.bodyParams ? Object.entries(n.bodyParams).reduce( - (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v), + (s, [k, v]) => s.replace(new RegExp(`\\{${escapeRe(k)}\\}`, 'g'), () => v), rawBody ) : rawBody; diff --git a/client/src/components/Todo/TodoListPanel.test.tsx b/client/src/components/Todo/TodoListPanel.test.tsx index 3901be5a9..592d56d6e 100644 --- a/client/src/components/Todo/TodoListPanel.test.tsx +++ b/client/src/components/Todo/TodoListPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-TODO-001 to FE-COMP-TODO-052 +// FE-COMP-TODO-001 to FE-COMP-TODO-079 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -24,6 +24,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); Object.defineProperty(window, 'innerWidth', { value: 0, writable: true, configurable: true }); }); @@ -202,6 +203,25 @@ describe('TodoListPanel', () => { expect(screen.queryByText('Future Task')).not.toBeInTheDocument(); }); + it('FE-COMP-TODO-079: Overdue uses the local calendar day, not the UTC one', () => { + // Freeze on an instant whose UTC date differs from the local one, so a UTC-derived + // "today" would move the cut-off a day in whichever direction this runner sits. + const offset = new Date(2026, 4, 15, 12).getTimezoneOffset(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 4, 15, offset > 0 ? 23 : 0, 30)); + const items = [ + buildTodoItem({ id: 1, name: 'Due Today', checked: 0, due_date: '2026-05-15' }), + buildTodoItem({ id: 2, name: 'Due Yesterday', checked: 0, due_date: '2026-05-14' }), + ]; + render(); + const overdueBtn = screen.getAllByRole('button').find( + b => b.textContent?.includes('Overdue') || b.getAttribute('title') === 'Overdue' + ); + fireEvent.click(overdueBtn!); + expect(screen.getByText('Due Yesterday')).toBeInTheDocument(); + expect(screen.queryByText('Due Today')).not.toBeInTheDocument(); + }); + it('FE-COMP-TODO-017: My Tasks filter shows only items assigned to current user', async () => { // Use default current_user_id: 1 from beforeEach; assign one item to user 1 const items = [ diff --git a/client/src/components/Todo/useTodoList.ts b/client/src/components/Todo/useTodoList.ts index afda45b4b..79bd22739 100644 --- a/client/src/components/Todo/useTodoList.ts +++ b/client/src/components/Todo/useTodoList.ts @@ -6,6 +6,7 @@ import { useTranslation } from '../../i18n' import apiClient from '../../api/client' import { formatDate as fmtDate } from '../../utils/formatters' import type { TodoItem } from '../../types' +import { localToday } from '../Planner/today' import type { FilterType, Member } from './todoListModel' /** @@ -65,7 +66,9 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu return Array.from(cats).sort() }, [items]) - const today = new Date().toISOString().split('T')[0] + // due_date is a bare calendar date, so "today" has to be the user's calendar + // day: toISOString() hands over the UTC one and shifts the overdue cut-off. + const today = localToday() const filtered = useMemo(() => { let result: TodoItem[] diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index ce298903a..75982b6cd 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-082 +// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-084 import type { Mock } from 'vitest'; import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -1214,4 +1214,37 @@ describe('TripFormModal', () => { await waitFor(() => expect(staleResolved).toBe(true)); expect(screen.queryByText('Stale failure')).not.toBeInTheDocument(); }); + + it('FE-COMP-TRIPFORM-083: a closed modal fetches nothing until it is opened', async () => { + // The trip planner keeps the modal mounted behind the page. + const seen: string[] = []; + server.use( + http.get('/api/auth/users', () => { seen.push('users'); return HttpResponse.json({ users: [] }); }), + http.get('/api/trips/:id/members', () => { seen.push('members'); return HttpResponse.json({ members: [] }); }), + ); + const trip = buildTrip({ id: 5 }); + const { rerender } = render(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(seen).toEqual([]); + + rerender(); + await waitFor(() => expect(seen).toContain('users')); + expect(seen).toContain('members'); + }); + + it('FE-COMP-TRIPFORM-084: staging another cover revokes the preview it replaces', async () => { + const revokeObjectURL = vi.fn(); + const original = URL.revokeObjectURL; + Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL }); + createObjectURL.mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:second'); + render(); + + fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); + await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1)); + fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); + + await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:first')); + Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: original }); + }); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index d3068bdd9..e253fda38 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -40,6 +40,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp const isEditing = !!trip const fileRef = useRef(null) const coverSearchSeq = useRef(0) + // The staged cover lives on as an object URL until it is replaced or the modal goes. + const previewUrlRef = useRef(null) const toast = useToast() const { t } = useTranslation() const currentUser = useAuthStore(s => s.user) @@ -104,6 +106,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp setCoverPreview(null) setCoverSearchQuery('') } + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current) + previewUrlRef.current = null + } setPendingCoverFile(null) setPendingUnsplashUrl(null) setCoverSearchResults([]) @@ -112,16 +118,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp setPendingDateShift(null) setDateShiftMode('keep_bookings') setError('') + setExistingMembers([]) + // The planner keeps this modal mounted while it is closed, so nothing may be + // fetched until it is actually open. if (isOpen) { authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => { if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) }).catch(() => {}) - } - authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) - if (trip) { - tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {}) - } else { - setExistingMembers([]) + authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) + if (trip) { + tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {}) + } } }, [trip, isOpen]) @@ -131,6 +138,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } }, [tripRemindersEnabled]) + // A staged cover that never got uploaded would otherwise pin the full image for + // as long as the tab lives. + useEffect(() => () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current) + }, []) + const handleSubmit = async (e) => { e.preventDefault() setError('') @@ -219,7 +232,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } else { // New trip: stage for upload after creation setPendingCoverFile(normalized) - setCoverPreview(URL.createObjectURL(normalized)) + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current) + previewUrlRef.current = URL.createObjectURL(normalized) + setCoverPreview(previewUrlRef.current) } } diff --git a/client/src/components/shared/CustomDateTimePicker.test.tsx b/client/src/components/shared/CustomDateTimePicker.test.tsx index d771b0f3f..fa72bc7ed 100644 --- a/client/src/components/shared/CustomDateTimePicker.test.tsx +++ b/client/src/components/shared/CustomDateTimePicker.test.tsx @@ -126,6 +126,26 @@ describe('CustomDatePicker', () => { expect(onChange).toHaveBeenCalledWith('2026-07-17'); }); + it('FE-COMP-DATEPICKER-031: text input rejects a day the month does not have', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /enter date manually/i })); + const input = screen.getByPlaceholderText('DD.MM.YYYY'); + fireEvent.change(input, { target: { value: '31.02.2026' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('FE-COMP-DATEPICKER-032: text input still accepts 29 February in a leap year', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /enter date manually/i })); + const input = screen.getByPlaceholderText('DD.MM.YYYY'); + fireEvent.change(input, { target: { value: '29.02.2024' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledWith('2024-02-29'); + }); + it('FE-COMP-DATEPICKER-014: Escape in text input cancels text mode', async () => { const user = userEvent.setup(); render(); diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index a04e38d52..d30227acf 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -240,6 +240,10 @@ export function CustomDatePicker({ } const year = y < 100 ? 2000 + y : y; if (m < 1 || m > 12 || d < 1 || d > 31) return; + // 31.02. parses fine as numbers but is no day at all; without the round-trip it would + // travel on as '2026-02-31' and come back out of new Date() as 3 March. + const probe = new Date(Date.UTC(year, m - 1, d)); + if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) return; const iso = `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; if (isOutOfRange(iso)) return; onChange(iso); diff --git a/plugin-sdk/src/cli/dev.ts b/plugin-sdk/src/cli/dev.ts index 5189696b6..51716929b 100644 --- a/plugin-sdk/src/cli/dev.ts +++ b/plugin-sdk/src/cli/dev.ts @@ -456,7 +456,7 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise // renders into docs/screenshot.png. ?theme=dark picks the dark palette. const chrome = url.searchParams.get('chrome') !== '0'; const theme = url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'; - return send(200, preview(id, String(manifest.type), previewTripId, { chrome, theme }), 'text/html; charset=utf-8'); + return send(200, preview(id, String(manifest.type), previewTripId, { chrome, theme, geoAllowed: grants.has('geolocation:read') }), 'text/html; charset=utf-8'); } // Static plugin UI at /ui (page/widget client bundle). The iframe loads @@ -598,7 +598,7 @@ ${LIVE_RELOAD}`; * toggle), proxies trek:invoke to the dev server's /api routes with the dev user, * and surfaces resize/notify/navigate. This is where the design kit renders themed. */ -function preview(id: string, type: string, tripId: number, opts: { chrome?: boolean; theme?: 'light' | 'dark' } = {}): string { +export function preview(id: string, type: string, tripId: number, opts: { chrome?: boolean; theme?: 'light' | 'dark'; geoAllowed?: boolean } = {}): string { const maxW = type === 'widget' ? '440px' : '1000px'; // `trek-plugin shot` renders this page into docs/screenshot.png — the image the plugin store // shows. The theme picker and the "runs sandboxed at an opaque origin" note are DEV furniture: @@ -666,6 +666,7 @@ function postCtx(){ if(f.contentWindow) f.contentWindow.postMessage(ctx(),"*"); var tt; function toast(msg){var el=document.getElementById("toast");el.textContent=msg;el.classList.add("on");clearTimeout(tt);tt=setTimeout(function(){el.classList.remove("on");},2200);} var SESSION_PREFIX="trek:plugin-session:1:"+encodeURIComponent(${JSON.stringify(id)})+":"; var SESSION_MAX_KEYS=32,SESSION_MAX_KEY_LENGTH=64,SESSION_MAX_VALUE_BYTES=1024; +var GEO_ALLOWED=${opts.geoAllowed ? 'true' : 'false'}; function sessionFail(m,code,message){f.contentWindow.postMessage({type:"trek:error",requestId:m.requestId,code:code,message:message},"*");} function sessionMessage(m){ try { @@ -715,7 +716,11 @@ window.addEventListener("message", function(ev){ } else if(m.type==="trek:geolocation"){ // Mirror the host's geolocation bridge with a fixed dev position, so - // trek.geolocation.* works in the preview without a permission prompt. + // trek.geolocation.* works in the preview without a browser prompt. The grant + // still gates it: without geolocation:read the host answers "forbidden" + // (PluginFrame.tsx), and a preview that answered anyway would green-light a + // plugin that production then refuses. + if(!GEO_ALLOWED){ f.contentWindow.postMessage({type:"trek:geolocation:result",requestId:m.requestId,error:"forbidden"},"*"); return; } var pos={lat:52.5163,lng:13.3777,accuracy:12,heading:null,speed:null,timestamp:Date.now()}; var act=m.action||"get"; if(act==="clear"){ clearInterval(geoTick); geoTick=null; f.contentWindow.postMessage({type:"trek:geolocation:result",requestId:m.requestId,cleared:true},"*"); } diff --git a/plugin-sdk/src/ui/kit.ts b/plugin-sdk/src/ui/kit.ts index ac09c6758..25b74a422 100644 --- a/plugin-sdk/src/ui/kit.ts +++ b/plugin-sdk/src/ui/kit.ts @@ -483,7 +483,12 @@ export const TREK_THEME_JS = `(function () { var seq = 0; var lastH = -1; - function send(msg) { try { window.parent.postMessage(msg, '*'); } catch (e) {} } + function send(msg) { try { window.parent.postMessage(msg, '*'); return true; } catch (e) { return false; } } + // postMessage throws synchronously on a value structured clone cannot carry (a + // function, a DOM node, a Proxy). Swallowing that left the request registered and + // the promise pending forever, so the same call rejects under the mock host and + // hangs in the real one. Drop the entry and reject with the host's own code. + function unsendable(bucket, id, code, message) { delete bucket[id]; var e = new Error(message); e.code = code; return e; } function setFlag(name, on) { if (on) { docEl.setAttribute(name, ''); } else { docEl.removeAttribute(name); } } function applyContext(m) { @@ -593,28 +598,28 @@ export const TREK_THEME_JS = `(function () { var id = 's' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:session:get', requestId: id, key: key, scope: opts && opts.scope }); + if (!send({ type: 'trek:session:get', requestId: id, key: key, scope: opts && opts.scope })) { reject(unsendable(pending, id, 'SESSION_INVALID_VALUE', 'session value must be JSON-serialisable')); } }); }, set: function (key, value, opts) { var id = 's' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:session:set', requestId: id, key: key, value: value, scope: opts && opts.scope }); + if (!send({ type: 'trek:session:set', requestId: id, key: key, value: value, scope: opts && opts.scope })) { reject(unsendable(pending, id, 'SESSION_INVALID_VALUE', 'session value must be JSON-serialisable')); } }); }, remove: function (key, opts) { var id = 's' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:session:remove', requestId: id, key: key, scope: opts && opts.scope }); + if (!send({ type: 'trek:session:remove', requestId: id, key: key, scope: opts && opts.scope })) { reject(unsendable(pending, id, 'SESSION_INVALID_VALUE', 'session value must be JSON-serialisable')); } }); }, clear: function (opts) { var id = 's' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:session:clear', requestId: id, scope: opts && opts.scope }); + if (!send({ type: 'trek:session:clear', requestId: id, scope: opts && opts.scope })) { reject(unsendable(pending, id, 'SESSION_INVALID_VALUE', 'session value must be JSON-serialisable')); } }); } }; @@ -641,7 +646,7 @@ export const TREK_THEME_JS = `(function () { var id = 'c' + (++seq); return new Promise(function (resolve) { pendingConfirms[id] = resolve; - send({ type: 'trek:confirm', requestId: id, title: opts.title, message: opts.message, confirmLabel: opts.confirmLabel, cancelLabel: opts.cancelLabel, danger: opts.danger }); + if (!send({ type: 'trek:confirm', requestId: id, title: opts.title, message: opts.message, confirmLabel: opts.confirmLabel, cancelLabel: opts.cancelLabel, danger: opts.danger })) { delete pendingConfirms[id]; resolve(false); } }); }, // Core-event names for the trip in view ({ event, tripId } only, no payloads) — @@ -655,7 +660,7 @@ export const TREK_THEME_JS = `(function () { var id = 'r' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:invoke', requestId: id, sub: sub, method: opts.method, body: opts.body }); + if (!send({ type: 'trek:invoke', requestId: id, sub: sub, method: opts.method, body: opts.body })) { reject(unsendable(pending, id, 'error', 'invoke body must be JSON-serialisable')); } }); }, // Host-brokered browser position (needs the geolocation:read grant; the diff --git a/plugin-sdk/test/permissions-parity.test.ts b/plugin-sdk/test/permissions-parity.test.ts index fc7713d32..c01d5754d 100644 --- a/plugin-sdk/test/permissions-parity.test.ts +++ b/plugin-sdk/test/permissions-parity.test.ts @@ -15,26 +15,32 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { HOOK_PERMISSION } from '../src/permissions.js'; +import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES } from '../src/index.js'; const here = path.dirname(fileURLToPath(import.meta.url)); const serverPlugins = path.resolve(here, '../../server/src/nest/plugins'); const envelopeFile = path.join(serverPlugins, 'protocol/envelope.ts'); const serverEgress = path.join(serverPlugins, 'runtime/egress-policy.ts'); const inMonorepo = fs.existsSync(envelopeFile) && fs.existsSync(serverEgress); +const hostFrame = path.resolve(here, '../../client/src/components/Plugins/PluginFrame.tsx'); describe.skipIf(!inMonorepo)('parity with the host', () => { - it('the generated host facts match the server's envelope.ts', () => { + it("the generated host facts match the server's envelope.ts", () => { // Not a regex scrape any more: server/scripts/gen-plugin-facts.ts imports envelope.ts // and writes src/generated/host-facts.ts, and its --check mode is a CI gate. This // asserts the checked-in artefact is the one the host would produce, so a standalone // publish (which has no server present) still ships the right list. - const generated = fs.readFileSync(path.resolve(here, '../src/generated/host-facts.ts'), 'utf8'); + // + // Compared as whole maps, both ways round: asserting the artefact merely CONTAINS + // each entry passed when the host grew a hook the artefact never learned about, + // which is the drift that actually ships a wrong permission list. const envelope = fs.readFileSync(envelopeFile, 'utf8'); + const block = envelope.match(/HOOK_PERMISSION[^{]*\{([\s\S]*?)\n\}/); + expect(block, 'HOOK_PERMISSION not found in envelope.ts').toBeTruthy(); + const theirs: Record = {}; + for (const m of block![1].matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) theirs[m[1]] = m[2]; - for (const [hook, perm] of Object.entries(HOOK_PERMISSION)) { - expect(generated, `${hook} missing from the generated facts`).toContain(`${hook}: '${perm}'`); - expect(envelope, `${hook} missing from envelope.ts`).toContain(`${hook}: '${perm}'`); - } + expect(theirs).toEqual({ ...HOOK_PERMISSION }); expect(Object.keys(HOOK_PERMISSION).length).toBeGreaterThan(0); }); @@ -55,3 +61,28 @@ describe.skipIf(!inMonorepo)('parity with the host', () => { for (const fn of fns) expect(body(ours, fn), `${fn} has drifted from the server`).toBe(body(theirs, fn)); }); }); + +describe.skipIf(!fs.existsSync(hostFrame))('parity with the host frame', () => { + it('the session storage contract is one contract in all three copies', () => { + // Nothing generates these: the limits are written out by hand in the SDK export + // authors import, in the dev preview's inlined host, and in the real frame, because + // the SDK ships standalone and cannot import across the package boundary. Bump one + // and the preview happily stores what production then rejects. + const frame = fs.readFileSync(hostFrame, 'utf8'); + const devServer = fs.readFileSync(path.resolve(here, '../src/cli/dev.ts'), 'utf8'); + + expect(frame).toContain(`const PLUGIN_SESSION_MAX_KEYS = ${PLUGIN_SESSION_MAX_KEYS}`); + expect(frame).toContain(`const PLUGIN_SESSION_MAX_KEY_LENGTH = ${PLUGIN_SESSION_MAX_KEY_LENGTH}`); + expect(frame).toContain(`const PLUGIN_SESSION_MAX_VALUE_BYTES = ${PLUGIN_SESSION_MAX_VALUE_BYTES}`); + expect(devServer).toContain( + `SESSION_MAX_KEYS=${PLUGIN_SESSION_MAX_KEYS},SESSION_MAX_KEY_LENGTH=${PLUGIN_SESSION_MAX_KEY_LENGTH},SESSION_MAX_VALUE_BYTES=${PLUGIN_SESSION_MAX_VALUE_BYTES}`, + ); + + // Same for the codes a plugin branches on — a rename in one copy is a silent + // behaviour change for anyone who developed against the other. + for (const code of ['SESSION_INVALID_KEY', 'SESSION_INVALID_VALUE', 'SESSION_VALUE_TOO_LARGE', 'SESSION_KEY_LIMIT', 'NO_TRIP_CONTEXT', 'SESSION_STORAGE_ERROR']) { + expect(frame, `${code} missing from the host frame`).toContain(`'${code}'`); + expect(devServer, `${code} missing from the dev preview`).toContain(`"${code}"`); + } + }); +}); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 4103b745c..15b4d2832 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -669,6 +669,21 @@ describe('dev-server SDK injection', () => { }); }); +describe('dev-server preview', () => { + // The preview plays the host, so it has to refuse what the host refuses. A preview + // that answered trek:geolocation without the grant green-lights a plugin production + // then breaks — the one drift direction this package cannot afford. + it('gates the geolocation bridge on geolocation:read, like PluginFrame', async () => { + const { preview } = await import('../src/cli/dev.js'); + const without = preview('demo', 'widget', 1, {}); + expect(without).toContain('var GEO_ALLOWED=false;'); + expect(without).toContain('if(!GEO_ALLOWED){'); + expect(without).toContain('error:"forbidden"'); + + expect(preview('demo', 'widget', 1, { geoAllowed: true })).toContain('var GEO_ALLOWED=true;'); + }); +}); + describe('dev db bind shapes', () => { let tmp: string; beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'devdb-')); }); diff --git a/plugin-sdk/test/ui-kit.test.ts b/plugin-sdk/test/ui-kit.test.ts index e329a4183..26a80fc88 100644 --- a/plugin-sdk/test/ui-kit.test.ts +++ b/plugin-sdk/test/ui-kit.test.ts @@ -70,3 +70,55 @@ describe('design kit', () => { expect(out.match(/