Files
TREK/server/tests/integration/trip-access-primitives.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

414 lines
19 KiB
TypeScript

/**
* 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<string> {
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);
});
});