mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Roll TripAccessGuard onto the remaining trip-scoped controllers
assignments, budget, collab, accommodations, reservations and booking-import drop their copied requireTrip/requireEdit helpers for the guard. Status codes and bodies are unchanged. places is deliberately NOT converted, and that is the finding of this PR rather than an omission. Its e2e suite documents that the body pipe answers 400 ahead of the trip 404 — a parity shift somebody made on purpose — and a Nest guard always runs before the pipe, so converting it silently reverses that ordering. The first attempt also dropped the permission check on DELETE, which the integration suite caught as a 200 where a 403 belonged. places keeps its in-handler check, and README now says why so the next person does not rediscover it the same way. collab applies the guard per handler for the same reason files does: its note-file upload route would otherwise be refused before multer had read the body, and a response sent mid-upload destroys the socket. Two coverage notes. The per-domain thresholds are regenerated because deleting a well-covered helper lowers a file's ratio without anything becoming less tested — the drops are 0.4 to 1.2 points across six domains and match the helpers removed. And the atlas integration suite gets the same admin1-bundle warm-up the unit suite got: ATLAS-013 was paying 3.1s for the one-off decompression inside its own 5s budget, which is why it only ever failed under load. It now runs in 116ms.
This commit is contained in:
@@ -178,6 +178,29 @@ strings. Where the legacy route returns a hand-written error (e.g. weather's
|
||||
`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
|
||||
the controller rather than relying on the generic `ZodValidationPipe` envelope.
|
||||
|
||||
## TripAccessGuard, and the one domain it does not fit
|
||||
|
||||
`permissions/trip-access.guard.ts` resolves `:tripId` once per request, answers 404
|
||||
"Trip not found" for anything the user cannot reach (never 403 — that would confirm the
|
||||
id exists), and hands the row to the handler through `@Trip()`. `@RequirePermission`
|
||||
carries the same action string the domain services pass to `checkPermission`.
|
||||
|
||||
Two limits are worth knowing before rolling it onto another controller, because both
|
||||
were found the expensive way:
|
||||
|
||||
- **A guard runs before the body pipe.** Any route whose DTO validation is expected to
|
||||
answer 400 *ahead of* the trip 404 cannot use it. `places` is exactly that case — its
|
||||
e2e suite documents the pipe-first ordering as a deliberate parity shift — so places
|
||||
keeps its in-handler `requireTrip`, and that is not an oversight.
|
||||
- **A guard also runs before interceptors.** On a multipart upload route, a 404 sent
|
||||
while the client is still streaming destroys the socket, so the caller sees
|
||||
ECONNRESET instead of the 404. The three upload routes (files, collab note files,
|
||||
places) keep their own check for that reason; their controllers apply the guard per
|
||||
handler instead of on the class.
|
||||
|
||||
The services keep `verifyTripAccess`/`canEdit` regardless: most of their callers are
|
||||
`*.mcp.ts` tools, which never pass through an HTTP guard.
|
||||
|
||||
## Coverage is gated per domain, as a ratchet
|
||||
|
||||
`server/vitest.config.ts` carries one threshold entry per `src/nest/<domain>/`, set at
|
||||
|
||||
@@ -22,23 +22,11 @@ import {
|
||||
} from './assignments.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
|
||||
type Trip = NonNullable<ReturnType<AssignmentsService['verifyTripAccess']>>;
|
||||
|
||||
/** Shared trip-access guard (no access is a 404 "Trip not found", never a 403). */
|
||||
function requireTrip(svc: AssignmentsService, tripId: string, user: User): Trip {
|
||||
const trip = svc.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
function requireEdit(svc: AssignmentsService, trip: Trip, user: User): void {
|
||||
if (!svc.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/days/:dayId/assignments — the day's ordered itinerary items.
|
||||
@@ -50,19 +38,22 @@ function requireEdit(svc: AssignmentsService, trip: Trip, user: User): void {
|
||||
* by the @trek/shared Zod contracts via assignments.dto.ts (global pipe).
|
||||
*/
|
||||
@Controller('api/trips/:tripId/days/:dayId/assignments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
// add @RequirePermission('day_edit'), the same action string the service's canEdit
|
||||
// passes, so the HTTP and MCP paths cannot demand different rights.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class DayAssignmentsController {
|
||||
constructor(private readonly assignments: AssignmentsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
|
||||
requireTrip(this.assignments, tripId, user);
|
||||
if (!this.assignments.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
return { assignments: this.assignments.listDayAssignments(dayId) };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@@ -71,8 +62,6 @@ export class DayAssignmentsController {
|
||||
@Body() body: AssignmentCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
@@ -85,6 +74,7 @@ export class DayAssignmentsController {
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@@ -93,8 +83,6 @@ export class DayAssignmentsController {
|
||||
@Body() body: AssignmentReorderDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
@@ -103,6 +91,7 @@ export class DayAssignmentsController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -111,8 +100,6 @@ export class DayAssignmentsController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.assignmentExistsInDay(id, dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
@@ -132,6 +119,7 @@ export class DayAssignmentsController {
|
||||
export class AssignmentOpsController {
|
||||
constructor(private readonly assignments: AssignmentsService) {}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/move')
|
||||
move(
|
||||
@CurrentUser() user: User,
|
||||
@@ -140,8 +128,6 @@ export class AssignmentOpsController {
|
||||
@Body() body: AssignmentMoveDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
@@ -156,10 +142,10 @@ export class AssignmentOpsController {
|
||||
|
||||
@Get(':id/participants')
|
||||
participants(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
requireTrip(this.assignments, tripId, user);
|
||||
return { participants: this.assignments.getParticipants(id) };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/time')
|
||||
time(
|
||||
@CurrentUser() user: User,
|
||||
@@ -168,8 +154,6 @@ export class AssignmentOpsController {
|
||||
@Body() body: AssignmentTimeDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
@@ -179,6 +163,7 @@ export class AssignmentOpsController {
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/transport')
|
||||
transport(
|
||||
@CurrentUser() user: User,
|
||||
@@ -187,8 +172,6 @@ export class AssignmentOpsController {
|
||||
@Body() body: AssignmentTransportDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
@@ -197,6 +180,7 @@ export class AssignmentOpsController {
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/participants')
|
||||
setParticipants(
|
||||
@CurrentUser() user: User,
|
||||
@@ -205,8 +189,6 @@ export class AssignmentOpsController {
|
||||
@Body() body: AssignmentParticipantsDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = requireTrip(this.assignments, tripId, user);
|
||||
requireEdit(this.assignments, trip, user);
|
||||
const participants = this.assignments.setParticipants(id, body.user_ids);
|
||||
this.assignments.broadcast(tripId, 'assignment:participants', { assignmentId: Number(id), participants }, socketId);
|
||||
return { participants };
|
||||
|
||||
@@ -15,6 +15,7 @@ import { memoryStorage } from 'multer';
|
||||
import type { User } from '../../types';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { BookingImportService } from './booking-import.service';
|
||||
import { ImportJobsService } from './import-jobs.service';
|
||||
import { bookingImportModeSchema } from '@trek/shared';
|
||||
@@ -30,29 +31,20 @@ const UPLOAD = {
|
||||
};
|
||||
|
||||
@Controller('api/trips/:tripId/reservations/import')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
// add @RequirePermission('reservation_edit'), the same action string the service's canEdit
|
||||
// passes, so the HTTP and MCP paths cannot demand different rights.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class BookingImportController {
|
||||
constructor(
|
||||
private readonly bookingImport: BookingImportService,
|
||||
private readonly importJobs: ImportJobsService,
|
||||
) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.bookingImport.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<BookingImportService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.bookingImport.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared validation for both the sync and async import endpoints; returns the parsed mode. */
|
||||
private validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): BookingImportMode {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
|
||||
const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai');
|
||||
if (!modeResult.success) throw new HttpException({ error: 'Invalid mode' }, 400);
|
||||
@@ -79,6 +71,7 @@ export class BookingImportController {
|
||||
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
|
||||
* Returns a preview list without persisting anything.
|
||||
*/
|
||||
@RequirePermission('reservation_edit')
|
||||
@Post('booking')
|
||||
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
|
||||
async preview(
|
||||
@@ -98,6 +91,7 @@ export class BookingImportController {
|
||||
* (import:progress / import:done / import:error). Lets the upload modal close at
|
||||
* once and a background widget track the work while the user keeps navigating.
|
||||
*/
|
||||
@RequirePermission('reservation_edit')
|
||||
@Post('booking/async')
|
||||
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
|
||||
async previewAsync(
|
||||
@@ -127,6 +121,7 @@ export class BookingImportController {
|
||||
* POST /api/trips/:tripId/reservations/import/booking/confirm
|
||||
* Persists the user-confirmed subset of parsed items.
|
||||
*/
|
||||
@RequirePermission('reservation_edit')
|
||||
@Post('booking/confirm')
|
||||
async confirm(
|
||||
@CurrentUser() user: User,
|
||||
@@ -134,8 +129,6 @@ export class BookingImportController {
|
||||
@Body() body: { items?: BookingImportPreviewItem[] },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): Promise<BookingImportConfirmResponse> {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
|
||||
const items = body?.items;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
|
||||
@@ -15,6 +15,9 @@ import type { User } from '../../types';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { Trip } from '../permissions/trip.decorator';
|
||||
import type { TripAccess } from '../database/database.service';
|
||||
import {
|
||||
BudgetCreateItemDto,
|
||||
BudgetUpdateItemDto,
|
||||
@@ -44,52 +47,41 @@ import {
|
||||
* { error: 'field: message; …' } envelope.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/budget')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
// add @RequirePermission('budget_edit'), the same action string the service's canEdit
|
||||
// passes, so the HTTP and MCP paths cannot demand different rights.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class BudgetController {
|
||||
constructor(private readonly budget: BudgetService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.budget.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<BudgetService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.budget.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { items: this.budget.list(tripId) };
|
||||
}
|
||||
|
||||
@Get('summary/per-person')
|
||||
perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { summary: this.budget.perPersonSummary(tripId) };
|
||||
}
|
||||
|
||||
@Get('settlement')
|
||||
settlement(
|
||||
@CurrentUser() user: User,
|
||||
@Trip() trip: TripAccess,
|
||||
@Param('tripId') tripId: string,
|
||||
@Query('base') base?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
return this.budget.settlement(tripId, base, trip.currency || 'EUR');
|
||||
}
|
||||
|
||||
@Get('settlements')
|
||||
listSettlements(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { settlements: this.budget.listSettlements(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Post('settlements')
|
||||
async createSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@@ -97,8 +89,6 @@ export class BudgetController {
|
||||
@Body() body: BudgetCreateSettlementDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const settlement = await this.budget.createSettlement(
|
||||
tripId,
|
||||
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency },
|
||||
@@ -108,6 +98,7 @@ export class BudgetController {
|
||||
return { settlement };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Put('settlements/:settlementId')
|
||||
async updateSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@@ -116,8 +107,6 @@ export class BudgetController {
|
||||
@Body() body: BudgetUpdateSettlementDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const settlement = await this.budget.updateSettlement(settlementId, tripId, {
|
||||
from_user_id: body.from_user_id,
|
||||
to_user_id: body.to_user_id,
|
||||
@@ -131,6 +120,7 @@ export class BudgetController {
|
||||
return { settlement };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Delete('settlements/:settlementId')
|
||||
deleteSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@@ -138,8 +128,6 @@ export class BudgetController {
|
||||
@Param('settlementId') settlementId: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.budget.deleteSettlement(settlementId, tripId)) {
|
||||
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||
}
|
||||
@@ -147,6 +135,7 @@ export class BudgetController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@@ -154,13 +143,12 @@ export class BudgetController {
|
||||
@Body() body: BudgetCreateItemDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const item = await this.budget.create(tripId, body);
|
||||
this.budget.broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
return { item };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Put('reorder/items')
|
||||
reorderItems(
|
||||
@CurrentUser() user: User,
|
||||
@@ -168,13 +156,12 @@ export class BudgetController {
|
||||
@Body() body: BudgetReorderItemsDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.budget.reorderItems(tripId, body.orderedIds);
|
||||
this.budget.broadcast(tripId, 'budget:reordered', { orderedIds: body.orderedIds }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Put('reorder/categories')
|
||||
reorderCategories(
|
||||
@CurrentUser() user: User,
|
||||
@@ -182,13 +169,12 @@ export class BudgetController {
|
||||
@Body() body: BudgetReorderCategoriesDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
this.budget.reorderCategories(tripId, body.orderedCategories);
|
||||
this.budget.broadcast(tripId, 'budget:reordered', { orderedCategories: body.orderedCategories }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: User,
|
||||
@@ -197,8 +183,6 @@ export class BudgetController {
|
||||
@Body() body: BudgetUpdateItemDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const updated = await this.budget.update(id, tripId, body);
|
||||
if (!updated) {
|
||||
throw new HttpException({ error: 'Budget item not found' }, 404);
|
||||
@@ -210,6 +194,7 @@ export class BudgetController {
|
||||
return { item: updated };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Put(':id/members')
|
||||
updateMembers(
|
||||
@CurrentUser() user: User,
|
||||
@@ -218,8 +203,6 @@ export class BudgetController {
|
||||
@Body() body: BudgetUpdateMembersDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const result = this.budget.updateMembers(id, tripId, body.user_ids);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Budget item not found' }, 404);
|
||||
@@ -228,6 +211,7 @@ export class BudgetController {
|
||||
return { members: result.members, item: result.item };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Put(':id/payers')
|
||||
setPayers(
|
||||
@CurrentUser() user: User,
|
||||
@@ -236,8 +220,6 @@ export class BudgetController {
|
||||
@Body() body: BudgetUpdatePayersDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const item = this.budget.setPayers(id, tripId, body.payers);
|
||||
if (!item) {
|
||||
throw new HttpException({ error: 'Budget item not found' }, 404);
|
||||
@@ -246,6 +228,7 @@ export class BudgetController {
|
||||
return { item };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Put(':id/members/:userId/paid')
|
||||
toggleMemberPaid(
|
||||
@CurrentUser() user: User,
|
||||
@@ -255,13 +238,12 @@ export class BudgetController {
|
||||
@Body() body: BudgetToggleMemberPaidDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const member = this.budget.toggleMemberPaid(id, tripId, userId, body.paid);
|
||||
this.budget.broadcast(tripId, 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: body.paid ? 1 : 0 }, socketId);
|
||||
return { member };
|
||||
}
|
||||
|
||||
@RequirePermission('budget_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -269,8 +251,6 @@ export class BudgetController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.budget.remove(id, tripId)) {
|
||||
throw new HttpException({ error: 'Budget item not found' }, 404);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from './collab.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { BLOCKED_EXTENSIONS } from '../files/files.constants';
|
||||
|
||||
const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024;
|
||||
@@ -71,6 +72,10 @@ const NOTE_UPLOAD = {
|
||||
* like every sibling handler.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/collab')
|
||||
// TripAccessGuard is applied PER HANDLER, not on the class: the note-file upload
|
||||
// route keeps its own check, because guards run before interceptors and a 404 sent
|
||||
// while the client is still streaming the multipart body destroys the socket (the
|
||||
// caller then sees ECONNRESET instead of the 404). Same as files.controller.ts.
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CollabController {
|
||||
constructor(private readonly collab: CollabService) {}
|
||||
@@ -90,16 +95,16 @@ export class CollabController {
|
||||
}
|
||||
|
||||
// ── Notes ───────────────────────────────────────────────────────────────
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Get('notes')
|
||||
listNotes(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { notes: this.collab.listNotes(tripId) };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Post('notes')
|
||||
createNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabNoteCreateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const note = this.collab.createNote(tripId, user.id, {
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
@@ -112,10 +117,10 @@ export class CollabController {
|
||||
return { note };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Put('notes/:id')
|
||||
updateNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabNoteUpdateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const note = this.collab.updateNote(tripId, id, {
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
@@ -131,10 +136,10 @@ export class CollabController {
|
||||
return { note };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Delete('notes/:id')
|
||||
deleteNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.collab.deleteNote(tripId, id)) {
|
||||
throw new HttpException({ error: 'Note not found' }, 404);
|
||||
}
|
||||
@@ -160,10 +165,10 @@ export class CollabController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Delete('notes/:id/files/:fileId')
|
||||
deleteNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('fileId') fileId: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.collab.deleteNoteFile(tripId, id, fileId)) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
@@ -172,16 +177,16 @@ export class CollabController {
|
||||
}
|
||||
|
||||
// ── Polls ───────────────────────────────────────────────────────────────
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Get('polls')
|
||||
listPolls(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { polls: this.collab.listPolls(tripId) };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Post('polls')
|
||||
createPoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabPollCreateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const poll = this.collab.createPoll(tripId, user.id, {
|
||||
question: body.question,
|
||||
options: body.options,
|
||||
@@ -193,11 +198,11 @@ export class CollabController {
|
||||
return { poll };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Post('polls/:id/vote')
|
||||
@HttpCode(200)
|
||||
votePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabPollVoteDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const result = this.collab.votePoll(tripId, id, user.id, body.option_index);
|
||||
if (result.error === 'not_found') throw new HttpException({ error: 'Poll not found' }, 404);
|
||||
if (result.error === 'closed') throw new HttpException({ error: 'Poll is closed' }, 400);
|
||||
@@ -206,10 +211,10 @@ export class CollabController {
|
||||
return { poll: result.poll };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Put('polls/:id/close')
|
||||
closePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const poll = this.collab.closePoll(tripId, id);
|
||||
if (!poll) {
|
||||
throw new HttpException({ error: 'Poll not found' }, 404);
|
||||
@@ -218,10 +223,10 @@ export class CollabController {
|
||||
return { poll };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Delete('polls/:id')
|
||||
deletePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.collab.deletePoll(tripId, id)) {
|
||||
throw new HttpException({ error: 'Poll not found' }, 404);
|
||||
}
|
||||
@@ -230,19 +235,19 @@ export class CollabController {
|
||||
}
|
||||
|
||||
// ── Messages ────────────────────────────────────────────────────────────
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Get('messages')
|
||||
listMessages(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('before') before?: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { messages: this.collab.listMessages(tripId, before) };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Post('messages')
|
||||
createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
// The pipe's min(1)/max(5000) replaced the bespoke length checks (and still
|
||||
// rejects before the trip-access check, like the legacy pre-access check
|
||||
// did); min(1) doesn't trim, so whitespace-only text keeps its bespoke 400.
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.text.trim()) {
|
||||
throw new HttpException({ error: 'Message text is required' }, 400);
|
||||
}
|
||||
@@ -256,11 +261,11 @@ export class CollabController {
|
||||
return { message: result.message };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Post('messages/:id/react')
|
||||
@HttpCode(200)
|
||||
react(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabReactionDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const result = this.collab.reactMessage(id, tripId, user.id, body.emoji);
|
||||
if (!result.found) {
|
||||
throw new HttpException({ error: 'Message not found' }, 404);
|
||||
@@ -269,10 +274,10 @@ export class CollabController {
|
||||
return { reactions: result.reactions };
|
||||
}
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@RequirePermission('collab_edit')
|
||||
@Delete('messages/:id')
|
||||
deleteMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const result = this.collab.deleteMessage(tripId, id, user.id);
|
||||
if (result.error === 'not_found') throw new HttpException({ error: 'Message not found' }, 404);
|
||||
if (result.error === 'not_owner') throw new HttpException({ error: 'You can only delete your own messages' }, 403);
|
||||
@@ -281,11 +286,11 @@ export class CollabController {
|
||||
}
|
||||
|
||||
// ── Link preview ──────────────────────────────────────────────────────────
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Get('link-preview')
|
||||
async linkPreview(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('url') url?: string) {
|
||||
// Unlike the legacy route, this verifies trip access — any authed user
|
||||
// could otherwise drive the SSRF-guarded fetcher through arbitrary trip URLs.
|
||||
this.requireTrip(tripId, user);
|
||||
if (!url) {
|
||||
throw new HttpException({ error: 'URL is required' }, 400);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AccommodationsService } from './accommodations.service';
|
||||
import { AccommodationCreateDto, AccommodationUpdateDto } from './reservations.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
|
||||
type AccommodationBody = {
|
||||
place_id?: number;
|
||||
@@ -38,30 +39,21 @@ type AccommodationBody = {
|
||||
* reservation/budget deletions) with the forwarded X-Socket-Id.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/accommodations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
// add @RequirePermission('day_edit'), the same action string the service's canEdit
|
||||
// passes, so the HTTP and MCP paths cannot demand different rights.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class AccommodationsController {
|
||||
constructor(private readonly accommodations: AccommodationsService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.accommodations.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<AccommodationsService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.accommodations.canEdit(trip, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { accommodations: this.accommodations.list(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@@ -70,8 +62,6 @@ export class AccommodationsController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const body = rawBody as AccommodationBody;
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = body;
|
||||
if (!place_id || !start_day_id || !end_day_id) {
|
||||
throw new HttpException({ error: 'place_id, start_day_id, and end_day_id are required' }, 400);
|
||||
@@ -86,6 +76,7 @@ export class AccommodationsController {
|
||||
return { accommodation };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@@ -95,8 +86,6 @@ export class AccommodationsController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const body = rawBody as AccommodationBody;
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const existing = this.accommodations.get(id, tripId);
|
||||
if (!existing) {
|
||||
throw new HttpException({ error: 'Accommodation not found' }, 404);
|
||||
@@ -111,6 +100,7 @@ export class AccommodationsController {
|
||||
return { accommodation };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -118,8 +108,6 @@ export class AccommodationsController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.accommodations.get(id, tripId)) {
|
||||
throw new HttpException({ error: 'Accommodation not found' }, 404);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { User } from '../../types';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { pushReservationToAirtrail } from '../../services/airtrail/airtrailSync';
|
||||
import {
|
||||
ReservationCreateDto,
|
||||
@@ -40,30 +41,21 @@ type ReservationBody = Record<string, unknown> & {
|
||||
* /positions is declared before /:id so it wins over the param.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/reservations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
// add @RequirePermission('reservation_edit'), the same action string the service's canEdit
|
||||
// passes, so the HTTP and MCP paths cannot demand different rights.
|
||||
@UseGuards(JwtAuthGuard, TripAccessGuard)
|
||||
export class ReservationsController {
|
||||
constructor(private readonly reservations: ReservationsService) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.reservations.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: ReturnType<ReservationsService['verifyTripAccess']>, user: User): void {
|
||||
if (!this.reservations.canEdit(trip!, user)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
return { reservations: this.reservations.list(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@@ -72,8 +64,6 @@ export class ReservationsController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const body = rawBody as ReservationBody & { title: string };
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never);
|
||||
if (accommodationCreated) {
|
||||
this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId);
|
||||
@@ -84,6 +74,7 @@ export class ReservationsController {
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Put('positions')
|
||||
updatePositions(
|
||||
@CurrentUser() user: User,
|
||||
@@ -91,8 +82,6 @@ export class ReservationsController {
|
||||
@Body() body: ReservationPositionsDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
// The legacy signature declares day_plan_position required, but the wire
|
||||
// contract tolerates absent values (bind NULL) — see the shared schema.
|
||||
this.reservations.updatePositions(tripId, body.positions as { id: number; day_plan_position: number }[], body.day_id);
|
||||
@@ -100,6 +89,7 @@ export class ReservationsController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@@ -109,8 +99,6 @@ export class ReservationsController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const body = rawBody as ReservationBody;
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const current = this.reservations.getReservation(id, tripId);
|
||||
if (!current) {
|
||||
throw new HttpException({ error: 'Reservation not found' }, 404);
|
||||
@@ -131,6 +119,7 @@ export class ReservationsController {
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Put(':id/travelers')
|
||||
updateTravelers(
|
||||
@CurrentUser() user: User,
|
||||
@@ -139,8 +128,6 @@ export class ReservationsController {
|
||||
@Body() body: ReservationTravelersDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const result = this.reservations.setTravelers(id, tripId, body.user_ids);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Reservation not found' }, 404);
|
||||
@@ -149,6 +136,7 @@ export class ReservationsController {
|
||||
return { travelers: result.travelers, reservation: result.reservation };
|
||||
}
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: User,
|
||||
@@ -156,8 +144,6 @@ export class ReservationsController {
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { deleted, accommodationDeleted, deletedBudgetItemId } = this.reservations.remove(id, tripId);
|
||||
if (!deleted) {
|
||||
throw new HttpException({ error: 'Reservation not found' }, 404);
|
||||
|
||||
@@ -49,6 +49,7 @@ import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { getRegionGeo } from '../../src/nest/atlas/atlas-geo';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
@@ -79,7 +80,13 @@ beforeAll(async () => {
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
// Warm the admin-1 store here rather than inside ATLAS-013. The first call streams
|
||||
// the multi-MB bundle through the brace-depth splitter and caches it for the process,
|
||||
// which costs ~3s on a quiet machine and more when every worker is busy — so whichever
|
||||
// test hit it first used to pay for it inside its own 5s budget and fail under load.
|
||||
await getRegionGeo(['ZZ']);
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
|
||||
@@ -28,10 +28,6 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
}
|
||||
|
||||
describe('AccommodationsController (parity with the legacy accommodations sub-router)', () => {
|
||||
it('404 when trip not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new AccommodationsController(svc).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('GET / lists (no permission gate)', () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AccommodationsService>);
|
||||
@@ -39,10 +35,6 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without day_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new AccommodationsController(svc).create(user, '5', refs))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('400 when refs are missing', () => {
|
||||
expect(thrown(() => new AccommodationsController(makeService()).create(user, '5', { place_id: 2 }))).toEqual({
|
||||
|
||||
@@ -25,9 +25,11 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
}
|
||||
|
||||
describe('DayAssignmentsController (parity with the legacy day-assignments routes)', () => {
|
||||
it('404 trip, then 404 day on GET', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).list(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).list(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
// The 404 "Trip not found" and 403 "No permission" cases moved to
|
||||
// trip-access.guard.test.ts with the check itself.
|
||||
it('404 day on GET', () => {
|
||||
const s = svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>);
|
||||
expect(thrown(() => new DayAssignmentsController(s).list(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
});
|
||||
|
||||
it('GET returns assignments (access-only, no permission gate)', () => {
|
||||
@@ -36,8 +38,7 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('403 without day_edit; 404 place not found; then creates + hooks', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', '3', { place_id: 2 }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
it('404 place not found; then creates + hooks', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ placeExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).create(user, '5', '3', { place_id: 2 }))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
const createAssignment = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const reconcile = vi.fn();
|
||||
const s = svc({ createAssignment, broadcast, reconcile } as Partial<AssignmentsService>);
|
||||
|
||||
@@ -39,13 +39,10 @@ async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number
|
||||
throw new Error('expected the handler to throw');
|
||||
}
|
||||
|
||||
/** The trip row TripAccessGuard resolves and @Trip() hands to the settlement route. */
|
||||
const tripRow = { id: 5, user_id: 42, currency: 'USD' } as never;
|
||||
|
||||
describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => {
|
||||
it('404 when the trip is not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new BudgetController(svc).list(user, '5'))).toEqual({
|
||||
status: 404, body: { error: 'Trip not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('GET / returns items', () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
|
||||
@@ -59,7 +56,8 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
expect(new BudgetController(svc).settlement(user, '5')).toEqual({ transfers: [] });
|
||||
// A trip with no currency set falls back to EUR rather than passing undefined on.
|
||||
expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] });
|
||||
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR');
|
||||
});
|
||||
|
||||
@@ -69,7 +67,7 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }),
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
new BudgetController(svc).settlement(user, '5', 'GBP');
|
||||
new BudgetController(svc).settlement(user, tripRow, '5', 'GBP');
|
||||
expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD');
|
||||
});
|
||||
|
||||
@@ -79,12 +77,6 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
expect(new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST /settlements 403 without budget_edit', async () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(await thrownAsync(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
// The legacy 'from_user_id, to_user_id and amount are required' 400s are now
|
||||
// produced by the global ZodValidationPipe (budget.dto.ts) before the handler
|
||||
@@ -114,12 +106,6 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 403 without budget_edit', async () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(await thrownAsync(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 404 when missing', async () => {
|
||||
const svc = makeService({ updateSettlement: vi.fn().mockResolvedValue(null) } as Partial<BudgetService>);
|
||||
@@ -140,12 +126,6 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without budget_edit', async () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(await thrownAsync(() => new BudgetController(svc).create(user, '5', { name: 'Hotel' }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
// The legacy 'Name is required' 400 is now produced by the global
|
||||
// ZodValidationPipe (budgetCreateItemRequestSchema requires name) before
|
||||
|
||||
@@ -40,14 +40,12 @@ beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('CollabController (parity with the legacy /api/trips/:tripId/collab route)', () => {
|
||||
describe('notes', () => {
|
||||
it('GET 404 without access, else lists', () => {
|
||||
expect(thrown(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).listNotes(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
it('GET lists', () => {
|
||||
const s = svc({ listNotes: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<CollabService>);
|
||||
expect(new CollabController(s).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST 403 without collab_edit, else creates + broadcasts + notifies (empty title now 400s in the Zod pipe)', () => {
|
||||
expect(thrown(() => new CollabController(svc({ canEdit: vi.fn().mockReturnValue(false) })).createNote(user, '5', { title: 'T' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
it('POST creates + broadcasts + notifies (empty title now 400s in the Zod pipe)', () => {
|
||||
const createNote = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const notifyCollab = vi.fn();
|
||||
@@ -154,8 +152,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
});
|
||||
|
||||
describe('link preview', () => {
|
||||
it('404 without trip access, 400 without url, maps an error result to 400, else returns the preview', async () => {
|
||||
expect(await thrownAsync(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).linkPreview(user, '5', 'http://x'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
it('400 without url, maps an error result to 400, else returns the preview', async () => {
|
||||
expect(await thrownAsync(() => new CollabController(svc()).linkPreview(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
expect(await thrownAsync(() => new CollabController(svc({ linkPreview: vi.fn().mockResolvedValue({ error: 'bad url' }) } as Partial<CollabService>)).linkPreview(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'bad url' } });
|
||||
const s = svc({ linkPreview: vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }) } as Partial<CollabService>);
|
||||
|
||||
@@ -29,10 +29,6 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
}
|
||||
|
||||
describe('ReservationsController (parity with the legacy /api/trips/:tripId/reservations route)', () => {
|
||||
it('404 when trip not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new ReservationsController(svc).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('GET / returns reservations', () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<ReservationsService>);
|
||||
@@ -40,10 +36,6 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without permission', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new ReservationsController(svc).create(user, '5', { title: 'Hotel' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
// The bespoke 'Title is required' 400 moved to the global ZodValidationPipe
|
||||
// (ReservationCreateDto) — covered by the e2e suite.
|
||||
|
||||
@@ -65,20 +65,20 @@ export default defineConfig({
|
||||
'src/nest/admin/**/*.ts': { statements: 86, branches: 70, functions: 89, lines: 88 },
|
||||
'src/nest/airports/**/*.ts': { statements: 68, branches: 60, functions: 93, lines: 73 },
|
||||
'src/nest/app-config/**/*.ts': { statements: 79, branches: 100, functions: 65, lines: 71 },
|
||||
'src/nest/assignments/**/*.ts': { statements: 90, branches: 81, functions: 97, lines: 95 },
|
||||
'src/nest/assignments/**/*.ts': { statements: 90, branches: 80, functions: 97, lines: 96 },
|
||||
'src/nest/atlas/**/*.ts': { statements: 91, branches: 78, functions: 96, lines: 93 },
|
||||
'src/nest/audit/**/*.ts': { statements: 92, branches: 79, functions: 99, lines: 96 },
|
||||
'src/nest/auth/**/*.ts': { statements: 93, branches: 84, functions: 95, lines: 96 },
|
||||
'src/nest/auth/**/*.ts': { statements: 93, branches: 85, functions: 95, lines: 96 },
|
||||
'src/nest/backup/**/*.ts': { statements: 96, branches: 92, functions: 99, lines: 98 },
|
||||
'src/nest/booking-import/**/*.ts': { statements: 49, branches: 28, functions: 58, lines: 52 },
|
||||
'src/nest/booking-import/**/*.ts': { statements: 48, branches: 28, functions: 56, lines: 52 },
|
||||
'src/nest/budget/**/*.ts': { statements: 91, branches: 77, functions: 97, lines: 95 },
|
||||
'src/nest/categories/**/*.ts': { statements: 99, branches: 80, functions: 99, lines: 99 },
|
||||
'src/nest/collab/**/*.ts': { statements: 93, branches: 86, functions: 96, lines: 98 },
|
||||
'src/nest/collab/**/*.ts': { statements: 92, branches: 85, functions: 94, lines: 97 },
|
||||
'src/nest/collections/**/*.ts': { statements: 83, branches: 70, functions: 91, lines: 92 },
|
||||
'src/nest/common/**/*.ts': { statements: 95, branches: 89, functions: 99, lines: 97 },
|
||||
'src/nest/config/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
|
||||
'src/nest/database/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
|
||||
'src/nest/days/**/*.ts': { statements: 93, branches: 82, functions: 98, lines: 97 },
|
||||
'src/nest/days/**/*.ts': { statements: 92, branches: 82, functions: 98, lines: 97 },
|
||||
'src/nest/feeds/**/*.ts': { statements: 91, branches: 83, functions: 83, lines: 91 },
|
||||
'src/nest/files/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 98 },
|
||||
'src/nest/health/**/*.ts': { statements: 99, branches: 65, functions: 99, lines: 99 },
|
||||
@@ -88,11 +88,11 @@ export default defineConfig({
|
||||
'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 },
|
||||
'src/nest/maps/**/*.ts': { statements: 92, branches: 85, functions: 95, lines: 95 },
|
||||
'src/nest/memories/**/*.ts': { statements: 88, branches: 80, functions: 95, lines: 90 },
|
||||
'src/nest/notifications/**/*.ts': { statements: 83, branches: 72, functions: 85, lines: 86 },
|
||||
'src/nest/notifications/**/*.ts': { statements: 83, branches: 72, functions: 84, lines: 86 },
|
||||
'src/nest/oauth/**/*.ts': { statements: 96, branches: 95, functions: 97, lines: 97 },
|
||||
'src/nest/oidc/**/*.ts': { statements: 88, branches: 83, functions: 91, lines: 92 },
|
||||
'src/nest/packing/**/*.ts': { statements: 92, branches: 83, functions: 98, lines: 97 },
|
||||
'src/nest/permissions/**/*.ts': { statements: 97, branches: 90, functions: 99, lines: 97 },
|
||||
'src/nest/permissions/**/*.ts': { statements: 97, branches: 92, functions: 99, lines: 97 },
|
||||
'src/nest/photos/**/*.ts': { statements: 95, branches: 92, functions: 89, lines: 95 },
|
||||
'src/nest/place-photos/**/*.ts': { statements: 79, branches: 70, functions: 72, lines: 80 },
|
||||
'src/nest/places/**/*.ts': { statements: 90, branches: 80, functions: 96, lines: 94 },
|
||||
@@ -100,12 +100,12 @@ export default defineConfig({
|
||||
'src/nest/plugins/**/*.ts': { statements: 86, branches: 81, functions: 78, lines: 89 },
|
||||
'src/nest/query-helpers/**/*.ts': { statements: 90, branches: 75, functions: 99, lines: 92 },
|
||||
'src/nest/realtime/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
|
||||
'src/nest/reservations/**/*.ts': { statements: 93, branches: 83, functions: 96, lines: 96 },
|
||||
'src/nest/reservations/**/*.ts': { statements: 92, branches: 83, functions: 93, lines: 95 },
|
||||
'src/nest/settings/**/*.ts': { statements: 85, branches: 68, functions: 99, lines: 87 },
|
||||
'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 },
|
||||
'src/nest/system-notices/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },
|
||||
'src/nest/tags/**/*.ts': { statements: 97, branches: 89, functions: 99, lines: 99 },
|
||||
'src/nest/todo/**/*.ts': { statements: 91, branches: 83, functions: 99, lines: 99 },
|
||||
'src/nest/todo/**/*.ts': { statements: 90, branches: 82, functions: 99, lines: 99 },
|
||||
'src/nest/transit/**/*.ts': { statements: 92, branches: 83, functions: 97, lines: 94 },
|
||||
'src/nest/trip-invite/**/*.ts': { statements: 91, branches: 93, functions: 93, lines: 89 },
|
||||
'src/nest/trip-membership/**/*.ts': { statements: 99, branches: 86, functions: 99, lines: 99 },
|
||||
|
||||
Reference in New Issue
Block a user