fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup
Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
- attendance: schemas accept the combined local datetimes the forms/service
use (new dateTimeString helpers in schemas/common.ts), breaks persist on
create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
enrollment QR generated locally via qrcode (CSP-blocked external service
also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
coercion in formatters.ts), billing_text persists on update, issued-order
status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
(shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
@hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
the Prisma 6 downgrade), drop deprecated @types stubs
Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
541
src/__tests__/trips.test.ts
Normal file
541
src/__tests__/trips.test.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user