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:
BOHA
2026-06-10 09:59:47 +02:00
parent d08e55a41a
commit 1826fc7976
44 changed files with 2812 additions and 622 deletions

541
src/__tests__/trips.test.ts Normal file
View 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);
});
});