From 8187a423e251025600d8c4ec41e72a40397099e1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 19:52:57 +0200 Subject: [PATCH] fix(admin): an invite for a trip that does not exist answers 404 instead of an empty 201 createInvite returned { error: 'Trip not found', status: 404 } and the admin controller never looked at it: POST /api/admin/invites with an unknown trip_id answered 201 with an empty body and wrote an admin.invite_create audit row for resource 'undefined'. The service throws the DomainError now, so the route answers 404 { error: 'Trip not found' } and writes neither the invite nor the audit row. --- server/scripts/eslint-baseline.json | 2 +- server/scripts/service-http-baseline.json | 1 - .../src/nest/auth/registration-invites.service.ts | 5 +++-- server/tests/integration/admin.test.ts | 15 ++++++++++++++- .../nest/registration-invites.service.test.ts | 4 ++-- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/server/scripts/eslint-baseline.json b/server/scripts/eslint-baseline.json index f357ce8a8..bf60443dd 100644 --- a/server/scripts/eslint-baseline.json +++ b/server/scripts/eslint-baseline.json @@ -16,7 +16,7 @@ "no-misleading-character-class": 1 }, "tests": { - "@typescript-eslint/no-explicit-any": 1569, + "@typescript-eslint/no-explicit-any": 1567, "@typescript-eslint/no-non-null-assertion": 1757, "@typescript-eslint/no-require-imports": 25, "@typescript-eslint/no-unsafe-function-type": 7, diff --git a/server/scripts/service-http-baseline.json b/server/scripts/service-http-baseline.json index f122288a3..741483c4e 100644 --- a/server/scripts/service-http-baseline.json +++ b/server/scripts/service-http-baseline.json @@ -4,7 +4,6 @@ "src/nest/auth/auth.service.ts": 5, "src/nest/auth/passkey.controller.ts": 1, "src/nest/auth/passkey.service.ts": 2, - "src/nest/auth/registration-invites.service.ts": 1, "src/nest/backup/backup-archive.ts": 8, "src/nest/backup/backup.impl.ts": 1, "src/nest/collab/collab.controller.ts": 7, diff --git a/server/src/nest/auth/registration-invites.service.ts b/server/src/nest/auth/registration-invites.service.ts index b4dc5f597..32cd03d54 100644 --- a/server/src/nest/auth/registration-invites.service.ts +++ b/server/src/nest/auth/registration-invites.service.ts @@ -70,8 +70,9 @@ export class RegistrationInvitesService { const parsed = Number.parseInt(String(data.trip_id)); if (!Number.isInteger(parsed) || !(await this.trips.existsById(parsed))) { // Used to bind null silently, handing back a plain registration invite - // the admin never asked for. - return { error: 'Trip not found', status: 404 }; + // the admin never asked for. Thrown, so the controller writes neither + // the invite nor its audit row and the admin sees the 404. + throw new DomainError(404, 'Trip not found'); } tripId = parsed; } diff --git a/server/tests/integration/admin.test.ts b/server/tests/integration/admin.test.ts index 5dde1b122..80c7bf76d 100644 --- a/server/tests/integration/admin.test.ts +++ b/server/tests/integration/admin.test.ts @@ -19,7 +19,7 @@ import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { findRow, insertRow, insertRowIgnoringConflict, updateRows } from '../helpers/factories/rows'; +import { countRows, findRow, insertRow, insertRowIgnoringConflict, updateRows } from '../helpers/factories/rows'; import { makeShareToken } from '../helpers/factories/trips'; import { makeVacayPlan } from '../helpers/factories/vacay'; import { readUser } from '../helpers/factories/users'; @@ -548,6 +548,19 @@ describe('Invite token management', () => { expect(res.body.invite.token).toBeDefined(); }); + it('ADMIN-013b — POST /admin/invites with a trip that does not exist answers 404 and writes nothing', async () => { + const { user: admin } = createAdmin(testDb); + + const res = await request(app) + .post('/api/admin/invites') + .set('Cookie', authCookie(admin.id)) + .send({ max_uses: 1, trip_id: 99999 }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found' }); + expect(await countRows(orm, InviteTokens, {})).toBe(0); + expect(await countRows(orm, AuditLog, { action: 'admin.invite_create' })).toBe(0); + }); + it('ADMIN-014 — DELETE /admin/invites/:id removes invite', async () => { const { user: admin } = createAdmin(testDb); const invite = createInviteToken(testDb, { created_by: admin.id }); diff --git a/server/tests/unit/nest/registration-invites.service.test.ts b/server/tests/unit/nest/registration-invites.service.test.ts index 3af28c3c7..cac28e32c 100644 --- a/server/tests/unit/nest/registration-invites.service.test.ts +++ b/server/tests/unit/nest/registration-invites.service.test.ts @@ -84,8 +84,8 @@ describe('Invites', () => { describe('Invites — trip binding', () => { it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', async () => { const { user: admin } = createAdmin(testDb); - expect(await svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' }); - expect(await svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 }); + await expect(svc.createInvite(admin.id, { trip_id: 99999 })).rejects.toMatchObject({ status: 404, publicMessage: 'Trip not found' }); + await expect(svc.createInvite(admin.id, { trip_id: 'not-a-number' })).rejects.toMatchObject({ status: 404 }); expect(await countRows(await sharedTestOrm(testDb), InviteTokens)).toBe(0); // An absent/blank binding is still a plain registration invite. expect((await svc.createInvite(admin.id, {}) as any).tripId).toBeNull();