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:
@@ -19,6 +19,9 @@ import tripsRoutes from "../routes/admin/trips";
|
|||||||
// WHOLE filtered set (not one 25-row page), honors the month filter in
|
// 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
|
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
|
||||||
// non-managers to their own trips exactly like the list does.
|
// 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. */
|
/** 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) {
|
async function authPut(path: string, token: string, body: unknown) {
|
||||||
return app.inject({
|
return app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -539,3 +554,88 @@ describe("GET /api/admin/trips/stats", () => {
|
|||||||
expect(filtered.json().data.count).toBe(2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -325,6 +325,25 @@ export default async function tripsRoutes(
|
|||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
const authData = request.authData!;
|
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) {
|
if (body.end_km < body.start_km) {
|
||||||
return error(
|
return error(
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
Reference in New Issue
Block a user