/** * Parity guards for the Plan 3c Task 0a async sweep, extended by Task 0b. * * `canAccessTrip`, `isOwner`, `rosterUserIds` and `getPlaceWithTags` went * from synchronous to `async` in Task 0a (with every caller updated to * `await` them), still delegating to `src/db/database.ts`'s free functions. * Task 0b deleted those free functions and moved the bodies onto * `TripsRepository.findAccessible`/`isOwner`, `TripMembersRepository * .rosterUserIds` and `PlacesRepository.findWithTagsAndRatings` — these four * tests still drive a REAL guarded route through the real `buildApp()` DI * graph, one per primitive, as owner / member / non-member / anonymous, and * still pin the legacy status + body, but now against the real repository * path rather than a stand-in. */ import { buildApp } from '../../src/bootstrap'; import { db as testDb } from '../../src/db/database'; import { Addons } from '../../src/db/entities/Addons.entity'; import { Categories } from '../../src/db/entities/Categories.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; import { closeMcpSessions } from '../../src/mcp/index'; import { authCookie, generateToken } from '../helpers/auth'; import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAssignment, } from '../helpers/factories'; import type { FactoryOrm } from '../helpers/factories/context'; import { findRow, updateRows } from '../helpers/factories/rows'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { MikroORM } from '@mikro-orm/core'; import type { INestApplication } from '@nestjs/common'; import type { Application } from 'express'; import request from 'supertest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; // task-0a-review F4: this mock no longer intercepts any of the four // primitives — `db/database.ts` holds none of their bodies any more, so // `buildDbMock`'s own `canAccessTrip`/`isOwner`/`getPlaceWithTags` // properties are dead code for this file's purposes. It stays only for what // every other integration/e2e/WS suite in this program uses it for: an // isolated snapshot db that `getRawConnection`/`db` swap the shared // better-sqlite3 handle to, which the REAL `MikroORM` binds to underneath // (`db/orm-driver.ts`) — so `TripsRepository`/`TripMembersRepository` // /`PlacesRepository` read the SAME rows this file seeds through `testDb`. vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); let nestApp: INestApplication; let app: Application; let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); orm = nestApp.get(MikroORM); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); }); afterAll(async () => { closeMcpSessions(); await nestApp.close(); testDb.close(); }); // ───────────────────────────────────────────────────────────────────────── // canAccessTrip — TripAccessGuard, exercised by GET /api/trips/:tripId/days // ───────────────────────────────────────────────────────────────────────── describe('canAccessTrip (async) — TripAccessGuard on GET /api/trips/:tripId/days', () => { it('PRIM-CAT-001 — owner: 200 with the trip days', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2026-06-01', end_date: '2026-06-02' }); const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(owner.id)); expect(res.status).toBe(200); expect(res.body.days).toHaveLength(2); }); it('PRIM-CAT-002 — member: 200 with the trip days', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2026-06-01', end_date: '2026-06-01' }); addTripMember(testDb, trip.id, member.id); const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(member.id)); expect(res.status).toBe(200); expect(res.body.days).toHaveLength(1); }); it('PRIM-CAT-003 — non-member: 404 { error: "Trip not found" } — never 403 (never confirms the id exists)', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(stranger.id)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); }); it('PRIM-CAT-004 — anonymous: 401', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app).get(`/api/trips/${trip.id}/days`); expect(res.status).toBe(401); }); }); // ───────────────────────────────────────────────────────────────────────── // isOwner — the delete_trip MCP tool (its only caller anywhere) // ───────────────────────────────────────────────────────────────────────── describe('isOwner (async) — the delete_trip MCP tool', () => { async function mcpSession(userId: number): Promise { const res = await request(app) .post('/mcp') .set('Authorization', `Bearer ${generateToken(userId)}`) .set('Accept', 'application/json, text/event-stream') .send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } }, }); expect(res.status).toBe(200); const sessionId = res.headers['mcp-session-id']; expect(sessionId).toBeTruthy(); return sessionId as string; } function deleteTripBody(tripId: number) { return { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'delete_trip', arguments: { tripId } } }; } /** /mcp answers as SSE; the JSON-RPC payload rides a `data:` line. */ function toolResult(text: string): { isError?: boolean; content?: { type: string; text: string }[] } { const line = text.split('\n').find((l) => l.startsWith('data:')); if (!line) throw new Error(`no SSE data frame in: ${text.slice(0, 200)}`); return ( ( JSON.parse(line.slice('data:'.length).trim()) as { result?: { isError?: boolean; content?: { type: string; text: string }[] }; } ).result ?? {} ); } beforeEach(async () => { await updateRows(orm, Addons, { id: 'mcp' }, { enabled: true }); }); it('PRIM-ISOWN-001 — owner: 200, deletes the trip', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const sessionId = await mcpSession(owner.id); const res = await request(app) .post('/mcp') .set('Authorization', `Bearer ${generateToken(owner.id)}`) .set('mcp-session-id', sessionId) .set('Accept', 'application/json, text/event-stream') .send(deleteTripBody(trip.id)); expect(res.status).toBe(200); const result = toolResult(res.text); expect(result.isError).toBeFalsy(); expect(JSON.parse(result.content?.[0]?.text ?? '{}')).toEqual({ success: true, tripId: trip.id }); expect(await findRow(orm, Trips, { id: trip.id })).toBeNull(); }); /** * Plan 3c Task 0b (R9): `isOwner`'s only real caller anywhere is this MCP * tool (inventory §0d), and `TripsService.isOwner` → `DatabaseService * .isOwner` is now `TripsRepository.isOwner` underneath — a repository * read reached through the MCP transport's request context (`nest-mcp * /registry.ts`'s `attachTool` note: bootstrap.ts's pathless * `mikroOrmRequestContext` middleware forks a context for every `/mcp` * request, same as any other Nest route). PRIM-ISOWN-001 above already * proves the happy path (the trip is actually deleted, which requires the * repository read to succeed); this is the explicit MCP-CTX-style structural assertion * (`mcp.test.ts`'s MCP-CTX-001/002 pattern) that no missing-context error * was swallowed along the way. */ it('PRIM-ISOWN-005 — the repository read inside isOwner runs inside the /mcp request context (MCP-CTX style, no cannotUseGlobalContext)', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const sessionId = await mcpSession(owner.id); const res = await request(app) .post('/mcp') .set('Authorization', `Bearer ${generateToken(owner.id)}`) .set('mcp-session-id', sessionId) .set('Accept', 'application/json, text/event-stream') .send(deleteTripBody(trip.id)); expect(res.status).toBe(200); const suspicious = errSpy.mock.calls .map((args) => args.map(String).join(' ')) .filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line)); expect(suspicious).toEqual([]); } finally { errSpy.mockRestore(); } }); it('PRIM-ISOWN-002 — member (not owner): refused with the same "no access" message as a stranger, trip untouched', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const sessionId = await mcpSession(member.id); const res = await request(app) .post('/mcp') .set('Authorization', `Bearer ${generateToken(member.id)}`) .set('mcp-session-id', sessionId) .set('Accept', 'application/json, text/event-stream') .send(deleteTripBody(trip.id)); expect(res.status).toBe(200); const result = toolResult(res.text); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toBe('Trip not found or access denied.'); expect(await findRow(orm, Trips, { id: trip.id })).not.toBeNull(); }); it('PRIM-ISOWN-003 — non-member/stranger: the identical refusal isOwner gives a member, trip untouched', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); const sessionId = await mcpSession(stranger.id); const res = await request(app) .post('/mcp') .set('Authorization', `Bearer ${generateToken(stranger.id)}`) .set('mcp-session-id', sessionId) .set('Accept', 'application/json, text/event-stream') .send(deleteTripBody(trip.id)); expect(res.status).toBe(200); const result = toolResult(res.text); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toBe('Trip not found or access denied.'); expect(await findRow(orm, Trips, { id: trip.id })).not.toBeNull(); }); it('PRIM-ISOWN-004 — anonymous: 401, no session established', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const res = await request(app).post('/mcp').send(deleteTripBody(trip.id)); expect(res.status).toBe(401); }); }); // ───────────────────────────────────────────────────────────────────────── // rosterUserIds — PUT /api/trips/:tripId/assignments/:id/participants // ───────────────────────────────────────────────────────────────────────── describe('rosterUserIds (async) — PUT .../assignments/:id/participants', () => { it('PRIM-ROSTER-001 — owner: 200, off-roster ids are dropped silently (no 400)', async () => { const { user: owner } = createUser(testDb); const { user: outsider } = createUser(testDb); const trip = createTrip(testDb, owner.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); const res = await request(app) .put(`/api/trips/${trip.id}/assignments/${assignment.id}/participants`) .set('Cookie', authCookie(owner.id)) .send({ user_ids: [owner.id, outsider.id] }); expect(res.status).toBe(200); expect(res.body.participants.map((p: { user_id: number }) => p.user_id).sort()).toEqual([owner.id]); }); it('PRIM-ROSTER-002 — member with edit rights: 200, keeps only roster ids (owner + member)', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const { user: outsider } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); const res = await request(app) .put(`/api/trips/${trip.id}/assignments/${assignment.id}/participants`) .set('Cookie', authCookie(member.id)) .send({ user_ids: [owner.id, member.id, outsider.id] }); expect(res.status).toBe(200); expect( res.body.participants.map((p: { user_id: number }) => p.user_id).sort((a: number, b: number) => a - b), ).toEqual([owner.id, member.id].sort((a, b) => a - b)); }); it('PRIM-ROSTER-003 — non-member: 404 { error: "Trip not found" }, guard refuses before rosterUserIds runs', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); const res = await request(app) .put(`/api/trips/${trip.id}/assignments/${assignment.id}/participants`) .set('Cookie', authCookie(stranger.id)) .send({ user_ids: [owner.id] }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); }); it('PRIM-ROSTER-004 — anonymous: 401', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); const res = await request(app) .put(`/api/trips/${trip.id}/assignments/${assignment.id}/participants`) .send({ user_ids: [owner.id] }); expect(res.status).toBe(401); }); }); // ───────────────────────────────────────────────────────────────────────── // getPlaceWithTags — GET /api/trips/:tripId/places/:id // ───────────────────────────────────────────────────────────────────────── describe('getPlaceWithTags (async) — GET /api/trips/:tripId/places/:id', () => { it('PRIM-GPWT-001 — owner: 200 with the hydrated place (category/tags/ratings shape)', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); // createPlace defaults category_id to the first seeded category when no // override is given (tests/helpers/factories.ts) — read back whatever it // actually assigned rather than assuming none, so this pins the real // production shape instead of a factory default that happens to be null. const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); const category = place.category_id ? await findRow(orm, Categories, { id: place.category_id }) : null; const expectedCategory = category ? { id: category.id, name: category.name, color: category.color, icon: category.icon } : null; const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(owner.id)); expect(res.status).toBe(200); // Plan 3c Task 0b: `getPlaceWithTags` is `PlacesRepository // .findWithTagsAndRatings` now, reached for real (this file's `vi.mock` // of `src/db/database` only swaps the underlying connection to an // isolated snapshot db — see the top-of-file note — it no longer stands // in for the primitive itself, which db/database.ts doesn't hold any // more). The full production shape, including the ratings aggregate the // old mocked stand-in omitted, is asserted directly rather than only a // subset. expect(res.body.place).toMatchObject({ id: place.id, name: 'Eiffel Tower', category: expectedCategory, tags: [], ratings: [], rating_avg: null, rating_count: 0, }); }); it('PRIM-GPWT-002 — member: 200 with the same hydrated place', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const place = createPlace(testDb, trip.id); const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(member.id)); expect(res.status).toBe(200); expect(res.body.place.id).toBe(place.id); }); it('PRIM-GPWT-003 — non-member: 404 { error: "Trip not found" } from the guard, before getPlaceWithTags runs', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); const place = createPlace(testDb, trip.id); const res = await request(app) .get(`/api/trips/${trip.id}/places/${place.id}`) .set('Cookie', authCookie(stranger.id)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); }); it('PRIM-GPWT-004 — anonymous: 401', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const place = createPlace(testDb, trip.id); const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`); expect(res.status).toBe(401); }); });