From 81b4cb51e7de22b07bc853d16a6c1d8e3dc7fe74 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 4 Jul 2026 03:22:26 +0200 Subject: [PATCH] fix(trips): reject on-behalf trip creation without trips.manage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /trips honored body.user_id for any trips.record holder — impersonation on create (reads were already scoped). Now an explicit Czech 403 unless the caller has the manager capability; manager on-behalf flow unchanged. +4 tests. Co-Authored-By: Claude Fable 5 --- src/__tests__/trips.test.ts | 100 ++++++++++++++++++++++++++++++++++++ src/routes/admin/trips.ts | 19 +++++++ 2 files changed, 119 insertions(+) diff --git a/src/__tests__/trips.test.ts b/src/__tests__/trips.test.ts index 652d39c..ca398ff 100644 --- a/src/__tests__/trips.test.ts +++ b/src/__tests__/trips.test.ts @@ -19,6 +19,9 @@ import tripsRoutes from "../routes/admin/trips"; // WHOLE filtered set (not one 25-row page), honors the month filter in // both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes // non-managers to their own trips exactly like the list does. +// 3. POST /trips rejects a non-manager filing a trip under someone else's +// user_id with an explicit Czech 403 (impersonation gap), while +// self-creates and manager on-behalf creates keep working. // --------------------------------------------------------------------------- /** Note-prefix for test-created rows so cleanup never touches real data. */ @@ -66,6 +69,18 @@ async function authGet(path: string, token: string) { }); } +async function authPost(path: string, token: string, body: unknown) { + return app.inject({ + method: "POST", + url: path, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + payload: body as object, + }); +} + async function authPut(path: string, token: string, body: unknown) { return app.inject({ method: "PUT", @@ -539,3 +554,88 @@ describe("GET /api/admin/trips/stats", () => { expect(filtered.json().data.count).toBe(2); }); }); + +describe("POST /api/admin/trips — on-behalf authorization", () => { + function postBody(params: { userId?: number; note: string }) { + return { + vehicle_id: vehicleAId, + ...(params.userId !== undefined ? { user_id: params.userId } : {}), + trip_date: "2098-11-10", + start_km: 100, + end_km: 150, + route_from: "Praha", + route_to: "Brno", + is_business: true, + notes: `${N}${params.note}`, + }; + } + + it("rejects a non-manager filing a trip under another user's id with 403", async () => { + const res = await authPost( + "/api/admin/trips", + scopeToken, + postBody({ userId: adminUserId, note: "authz_spoof" }), + ); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual({ + success: false, + error: "Nemáte oprávnění zadat jízdu za jiného uživatele", + }); + + // …and the trip must NOT have been created. + const created = await prisma.trips.findFirst({ + where: { notes: `${N}authz_spoof` }, + }); + expect(created).toBeNull(); + }); + + it("lets a non-manager create a trip without user_id (defaults to self)", async () => { + const res = await authPost( + "/api/admin/trips", + scopeToken, + postBody({ note: "authz_self_implicit" }), + ); + + expect(res.statusCode).toBe(201); + expect(res.json().success).toBe(true); + + const created = await prisma.trips.findFirst({ + where: { notes: `${N}authz_self_implicit` }, + }); + expect(created).not.toBeNull(); + expect(created!.user_id).toBe(scopeUserId); + }); + + it("lets a non-manager create a trip with their OWN user_id", async () => { + const res = await authPost( + "/api/admin/trips", + scopeToken, + postBody({ userId: scopeUserId, note: "authz_self_explicit" }), + ); + + expect(res.statusCode).toBe(201); + + const created = await prisma.trips.findFirst({ + where: { notes: `${N}authz_self_explicit` }, + }); + expect(created).not.toBeNull(); + expect(created!.user_id).toBe(scopeUserId); + }); + + it("lets a manager/admin create a trip on behalf of another user", async () => { + const res = await authPost( + "/api/admin/trips", + adminToken, + postBody({ userId: scopeUserId, note: "authz_on_behalf" }), + ); + + expect(res.statusCode).toBe(201); + + const created = await prisma.trips.findFirst({ + where: { notes: `${N}authz_on_behalf` }, + }); + expect(created).not.toBeNull(); + expect(created!.user_id).toBe(scopeUserId); + }); +}); diff --git a/src/routes/admin/trips.ts b/src/routes/admin/trips.ts index fd4fbd6..21e5028 100644 --- a/src/routes/admin/trips.ts +++ b/src/routes/admin/trips.ts @@ -325,6 +325,25 @@ export default async function tripsRoutes( const body = parsed.data; const authData = request.authData!; + // Only managers may file a trip on behalf of someone else — the same + // manager detection buildTripsWhere uses for read scoping (admin role + // bypasses permission checks entirely). Explicit Czech 403 instead of + // silently ignoring the submitted user_id. + const isManager = + authData.roleName === "admin" || + authData.permissions.includes("trips.manage"); + if ( + body.user_id !== undefined && + Number(body.user_id) !== authData.userId && + !isManager + ) { + return error( + reply, + "Nemáte oprávnění zadat jízdu za jiného uživatele", + 403, + ); + } + if (body.end_km < body.start_km) { return error( reply,