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>
642 lines
19 KiB
TypeScript
642 lines
19 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
import Fastify from "fastify";
|
|
import cookie from "@fastify/cookie";
|
|
import rateLimit from "@fastify/rate-limit";
|
|
import jwt from "jsonwebtoken";
|
|
import prisma from "../config/database";
|
|
import { config } from "../config/env";
|
|
import { securityHeaders } from "../middleware/security";
|
|
import tripsRoutes from "../routes/admin/trips";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route-level tests for the trips domain:
|
|
//
|
|
// 1. PUT /trips/:id with vehicle_id actually moves the trip to the new vehicle
|
|
// (UpdateTripSchema previously had no vehicle_id, so the edit modal's
|
|
// vehicle change was silently dropped while the UI reported success) and
|
|
// recomputes actual_km on BOTH vehicles.
|
|
// 2. GET /trips/stats aggregates count/total/business/private km over the
|
|
// 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. */
|
|
const N = "trips_test_";
|
|
|
|
let app: Awaited<ReturnType<typeof buildApp>>;
|
|
let adminUserId: number;
|
|
let adminToken: string;
|
|
let scopeUserId: number;
|
|
let scopeRoleId: number;
|
|
let scopeToken: string;
|
|
let vehicleAId: number;
|
|
let vehicleBId: number;
|
|
|
|
async function buildApp() {
|
|
const a = Fastify({ logger: false });
|
|
await a.register(cookie);
|
|
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
|
a.addHook("onRequest", securityHeaders);
|
|
await a.register(tripsRoutes, { prefix: "/api/admin/trips" });
|
|
return a;
|
|
}
|
|
|
|
/**
|
|
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
|
|
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
|
|
*/
|
|
function generateToken(user: {
|
|
id: number;
|
|
username: string;
|
|
roleName: string | null;
|
|
}): string {
|
|
return jwt.sign(
|
|
{ sub: user.id, username: user.username, role: user.roleName },
|
|
config.jwt.secret,
|
|
{ expiresIn: "15m" },
|
|
);
|
|
}
|
|
|
|
async function authGet(path: string, token: string) {
|
|
return app.inject({
|
|
method: "GET",
|
|
url: path,
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
}
|
|
|
|
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",
|
|
url: path,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
payload: body as object,
|
|
});
|
|
}
|
|
|
|
function tripRow(params: {
|
|
userId: number;
|
|
vehicleId: number;
|
|
date: string;
|
|
startKm: number;
|
|
dist: number;
|
|
isBusiness: boolean;
|
|
}) {
|
|
return {
|
|
user_id: params.userId,
|
|
vehicle_id: params.vehicleId,
|
|
trip_date: new Date(params.date),
|
|
start_km: params.startKm,
|
|
end_km: params.startKm + params.dist,
|
|
route_from: "Praha",
|
|
route_to: "Brno",
|
|
is_business: params.isBusiness,
|
|
notes: `${N}fixture`,
|
|
};
|
|
}
|
|
|
|
async function cleanupFixtureTrips() {
|
|
await prisma.trips.deleteMany({ where: { notes: { contains: N } } });
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp();
|
|
|
|
const admin = await prisma.users.findFirst({
|
|
where: { roles: { name: "admin" } },
|
|
include: { roles: true },
|
|
});
|
|
if (!admin) throw new Error("Test setup: admin user not found");
|
|
adminUserId = admin.id;
|
|
adminToken = generateToken({
|
|
id: admin.id,
|
|
username: admin.username,
|
|
roleName: admin.roles?.name ?? null,
|
|
});
|
|
|
|
// Dedicated non-admin user with trips.record + trips.history (but NOT
|
|
// trips.manage) for the own-trips scoping tests.
|
|
const stamp = Date.now().toString(36);
|
|
const role = await prisma.roles.create({
|
|
data: {
|
|
name: `${N}role_${stamp}`,
|
|
display_name: "Trips Scope Test",
|
|
},
|
|
});
|
|
scopeRoleId = role.id;
|
|
const perms = await prisma.permissions.findMany({
|
|
where: { name: { in: ["trips.record", "trips.history"] } },
|
|
select: { id: true },
|
|
});
|
|
if (perms.length !== 2)
|
|
throw new Error(
|
|
"Test setup: trips.record/trips.history permissions missing",
|
|
);
|
|
await prisma.role_permissions.createMany({
|
|
data: perms.map((p) => ({ role_id: role.id, permission_id: p.id })),
|
|
});
|
|
const scopeUser = await prisma.users.create({
|
|
data: {
|
|
username: `${N}user_${stamp}`,
|
|
first_name: "Trips",
|
|
last_name: "Scope",
|
|
email: `${N}${stamp}@test.local`,
|
|
password_hash:
|
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
|
role_id: role.id,
|
|
is_active: true,
|
|
},
|
|
});
|
|
scopeUserId = scopeUser.id;
|
|
if (scopeUserId === adminUserId)
|
|
throw new Error("scope user must be distinct from admin");
|
|
scopeToken = generateToken({
|
|
id: scopeUser.id,
|
|
username: scopeUser.username,
|
|
roleName: role.name,
|
|
});
|
|
|
|
// Two dedicated vehicles (spz is unique, VarChar(20)).
|
|
const vehicleA = await prisma.vehicles.create({
|
|
data: { spz: `TTA-${stamp}`, name: "Trips Test A", initial_km: 500 },
|
|
});
|
|
const vehicleB = await prisma.vehicles.create({
|
|
data: { spz: `TTB-${stamp}`, name: "Trips Test B", initial_km: 200 },
|
|
});
|
|
vehicleAId = vehicleA.id;
|
|
vehicleBId = vehicleB.id;
|
|
|
|
await cleanupFixtureTrips();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await cleanupFixtureTrips();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanupFixtureTrips();
|
|
await prisma.vehicles
|
|
.deleteMany({ where: { id: { in: [vehicleAId, vehicleBId] } } })
|
|
.catch(() => {});
|
|
if (scopeUserId)
|
|
await prisma.users
|
|
.deleteMany({ where: { id: scopeUserId } })
|
|
.catch(() => {});
|
|
if (scopeRoleId)
|
|
await prisma.roles
|
|
.deleteMany({ where: { id: scopeRoleId } })
|
|
.catch(() => {});
|
|
if (app) await app.close();
|
|
});
|
|
|
|
describe("PUT /api/admin/trips/:id — vehicle_id", () => {
|
|
it("changes the trip's vehicle and recomputes actual_km on both vehicles", async () => {
|
|
const trip = await prisma.trips.create({
|
|
data: tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-04-10",
|
|
startKm: 1000,
|
|
dist: 500,
|
|
isBusiness: true,
|
|
}),
|
|
});
|
|
|
|
// The edit form sends the id as a string (Select value) — intIdFromForm
|
|
// must coerce it.
|
|
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
|
vehicle_id: String(vehicleBId),
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().success).toBe(true);
|
|
|
|
const updated = await prisma.trips.findUnique({ where: { id: trip.id } });
|
|
expect(updated!.vehicle_id).toBe(vehicleBId);
|
|
|
|
// New vehicle picks up the trip's odometer reading…
|
|
const vehB = await prisma.vehicles.findUnique({
|
|
where: { id: vehicleBId },
|
|
});
|
|
expect(vehB!.actual_km).toBe(1500);
|
|
// …and the old vehicle falls back to initial_km (no trips left on it).
|
|
const vehA = await prisma.vehicles.findUnique({
|
|
where: { id: vehicleAId },
|
|
});
|
|
expect(vehA!.actual_km).toBe(500);
|
|
});
|
|
|
|
it("recomputes actual_km when end_km changes and the vehicle stays the same", async () => {
|
|
// Two trips on vehicle A — the recompute must take MAX(end_km) over ALL
|
|
// of the vehicle's trips, not just the edited one.
|
|
const trip1 = await prisma.trips.create({
|
|
data: tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-09-10",
|
|
startKm: 1000,
|
|
dist: 500, // end_km 1500
|
|
isBusiness: true,
|
|
}),
|
|
});
|
|
const trip2 = await prisma.trips.create({
|
|
data: tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-09-12",
|
|
startKm: 1800,
|
|
dist: 200, // end_km 2000
|
|
isBusiness: true,
|
|
}),
|
|
});
|
|
|
|
// Raising the MAX trip's end_km (vehicle UNCHANGED) moves the odometer up…
|
|
const raise = await authPut(`/api/admin/trips/${trip2.id}`, adminToken, {
|
|
end_km: 2200,
|
|
});
|
|
expect(raise.statusCode).toBe(200);
|
|
let vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
|
|
expect(vehA!.actual_km).toBe(2200);
|
|
|
|
// …while editing the non-MAX trip recomputes but keeps MAX(end_km).
|
|
const lower = await authPut(`/api/admin/trips/${trip1.id}`, adminToken, {
|
|
end_km: 1600,
|
|
});
|
|
expect(lower.statusCode).toBe(200);
|
|
const updated = await prisma.trips.findUnique({ where: { id: trip1.id } });
|
|
expect(updated!.end_km).toBe(1600);
|
|
expect(updated!.vehicle_id).toBe(vehicleAId);
|
|
vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
|
|
expect(vehA!.actual_km).toBe(2200);
|
|
});
|
|
|
|
it("writes an audit row with oldValues/newValues on update", async () => {
|
|
const trip = await prisma.trips.create({
|
|
data: tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-10-10",
|
|
startKm: 100,
|
|
dist: 50, // end_km 150
|
|
isBusiness: true,
|
|
}),
|
|
});
|
|
|
|
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
|
end_km: 180,
|
|
route_to: "Ostrava",
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const audit = await prisma.audit_logs.findFirst({
|
|
where: { action: "update", entity_type: "trip", entity_id: trip.id },
|
|
orderBy: { id: "desc" },
|
|
});
|
|
expect(audit).not.toBeNull();
|
|
expect(audit!.user_id).toBe(adminUserId);
|
|
|
|
// oldValues = the full pre-update row, newValues = the changed fields.
|
|
expect(audit!.old_values).toBeTruthy();
|
|
expect(audit!.new_values).toBeTruthy();
|
|
const oldValues = JSON.parse(audit!.old_values!);
|
|
const newValues = JSON.parse(audit!.new_values!);
|
|
expect(oldValues.end_km).toBe(150);
|
|
expect(oldValues.route_to).toBe("Brno");
|
|
expect(oldValues.vehicle_id).toBe(vehicleAId);
|
|
expect(newValues).toEqual({ end_km: 180, route_to: "Ostrava" });
|
|
});
|
|
|
|
it("rejects an invalid vehicle_id with 400", async () => {
|
|
const trip = await prisma.trips.create({
|
|
data: tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-04-11",
|
|
startKm: 100,
|
|
dist: 50,
|
|
isBusiness: true,
|
|
}),
|
|
});
|
|
|
|
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
|
|
vehicle_id: "abc",
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
const unchanged = await prisma.trips.findUnique({ where: { id: trip.id } });
|
|
expect(unchanged!.vehicle_id).toBe(vehicleAId);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/admin/trips/stats", () => {
|
|
it("aggregates over the WHOLE filtered set, beyond one 25-row page", async () => {
|
|
// 20 business trips of 10 km + 10 private trips of 7 km in 2098-05.
|
|
const rows = [];
|
|
for (let i = 0; i < 20; i++) {
|
|
rows.push(
|
|
tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-05-10",
|
|
startKm: 1000 + i * 10,
|
|
dist: 10,
|
|
isBusiness: true,
|
|
}),
|
|
);
|
|
}
|
|
for (let i = 0; i < 10; i++) {
|
|
rows.push(
|
|
tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleBId,
|
|
date: "2098-05-15",
|
|
startKm: 2000 + i * 7,
|
|
dist: 7,
|
|
isBusiness: false,
|
|
}),
|
|
);
|
|
}
|
|
await prisma.trips.createMany({ data: rows });
|
|
|
|
// The list truncates to the default 25-row page but reports the real total…
|
|
const listRes = await authGet(
|
|
"/api/admin/trips?month=5&year=2098",
|
|
adminToken,
|
|
);
|
|
expect(listRes.statusCode).toBe(200);
|
|
const listBody = listRes.json();
|
|
expect(listBody.data).toHaveLength(25);
|
|
expect(listBody.pagination.total).toBe(30);
|
|
expect(listBody.pagination.total_pages).toBe(2);
|
|
|
|
// …while the stats endpoint covers all 30 rows.
|
|
const res = await authGet(
|
|
"/api/admin/trips/stats?month=5&year=2098",
|
|
adminToken,
|
|
);
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toEqual({
|
|
count: 30,
|
|
total_km: 20 * 10 + 10 * 7,
|
|
business_km: 200,
|
|
private_km: 70,
|
|
});
|
|
});
|
|
|
|
it("honors the month filter in both wire formats", async () => {
|
|
await prisma.trips.createMany({
|
|
data: [
|
|
tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-06-10",
|
|
startKm: 100,
|
|
dist: 10,
|
|
isBusiness: true,
|
|
}),
|
|
tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-06-12",
|
|
startKm: 110,
|
|
dist: 20,
|
|
isBusiness: false,
|
|
}),
|
|
tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-07-05",
|
|
startKm: 130,
|
|
dist: 40,
|
|
isBusiness: true,
|
|
}),
|
|
],
|
|
});
|
|
|
|
// Combined "YYYY-MM" format (TripsHistory)
|
|
const june = await authGet(
|
|
"/api/admin/trips/stats?month=2098-06",
|
|
adminToken,
|
|
);
|
|
expect(june.statusCode).toBe(200);
|
|
expect(june.json().data).toEqual({
|
|
count: 2,
|
|
total_km: 30,
|
|
business_km: 10,
|
|
private_km: 20,
|
|
});
|
|
|
|
// Split month+year format (TripsAdmin)
|
|
const july = await authGet(
|
|
"/api/admin/trips/stats?month=7&year=2098",
|
|
adminToken,
|
|
);
|
|
expect(july.statusCode).toBe(200);
|
|
expect(july.json().data).toEqual({
|
|
count: 1,
|
|
total_km: 40,
|
|
business_km: 40,
|
|
private_km: 0,
|
|
});
|
|
});
|
|
|
|
it("scopes non-managers to their own trips (user_id param ignored)", async () => {
|
|
await prisma.trips.createMany({
|
|
data: [
|
|
// 2 trips for the scope user, 3 for the admin — same month.
|
|
tripRow({
|
|
userId: scopeUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-08-05",
|
|
startKm: 100,
|
|
dist: 10,
|
|
isBusiness: true,
|
|
}),
|
|
tripRow({
|
|
userId: scopeUserId,
|
|
vehicleId: vehicleAId,
|
|
date: "2098-08-06",
|
|
startKm: 110,
|
|
dist: 15,
|
|
isBusiness: false,
|
|
}),
|
|
tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleBId,
|
|
date: "2098-08-07",
|
|
startKm: 200,
|
|
dist: 100,
|
|
isBusiness: true,
|
|
}),
|
|
tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleBId,
|
|
date: "2098-08-08",
|
|
startKm: 300,
|
|
dist: 100,
|
|
isBusiness: true,
|
|
}),
|
|
tripRow({
|
|
userId: adminUserId,
|
|
vehicleId: vehicleBId,
|
|
date: "2098-08-09",
|
|
startKm: 400,
|
|
dist: 100,
|
|
isBusiness: true,
|
|
}),
|
|
],
|
|
});
|
|
|
|
// Non-manager: only own trips, even when explicitly requesting another user.
|
|
const own = await authGet(
|
|
"/api/admin/trips/stats?month=8&year=2098",
|
|
scopeToken,
|
|
);
|
|
expect(own.statusCode).toBe(200);
|
|
expect(own.json().data).toEqual({
|
|
count: 2,
|
|
total_km: 25,
|
|
business_km: 10,
|
|
private_km: 15,
|
|
});
|
|
|
|
const spoofed = await authGet(
|
|
`/api/admin/trips/stats?month=8&year=2098&user_id=${adminUserId}`,
|
|
scopeToken,
|
|
);
|
|
expect(spoofed.statusCode).toBe(200);
|
|
expect(spoofed.json().data.count).toBe(2);
|
|
|
|
// The list applies the exact same scoping.
|
|
const list = await authGet(
|
|
"/api/admin/trips?month=8&year=2098",
|
|
scopeToken,
|
|
);
|
|
expect(list.statusCode).toBe(200);
|
|
expect(list.json().data).toHaveLength(2);
|
|
|
|
// Manager (admin) sees everything in the month.
|
|
const all = await authGet(
|
|
"/api/admin/trips/stats?month=8&year=2098",
|
|
adminToken,
|
|
);
|
|
expect(all.statusCode).toBe(200);
|
|
expect(all.json().data.count).toBe(5);
|
|
expect(all.json().data.total_km).toBe(325);
|
|
|
|
// Manager can filter to a single user.
|
|
const filtered = await authGet(
|
|
`/api/admin/trips/stats?month=8&year=2098&user_id=${scopeUserId}`,
|
|
adminToken,
|
|
);
|
|
expect(filtered.statusCode).toBe(200);
|
|
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);
|
|
});
|
|
});
|