fix(trips): reject on-behalf trip creation without trips.manage

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 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-07-04 03:22:26 +02:00
parent a274a49e90
commit 81b4cb51e7
2 changed files with 119 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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,