fix: 2026-06-09 full-codebase audit hardening

Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -1,8 +1,8 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { requireAnyPermission, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, paginated, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
@@ -10,77 +10,83 @@ import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
export default async function tripsRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
const hasTripsAccess =
authData.permissions.includes("trips.manage") ||
authData.permissions.includes("trips.record") ||
authData.permissions.includes("trips.history");
if (!hasTripsAccess) return error(reply, "Nedostatečná oprávnění", 403);
const isAdmin = authData.permissions.includes("trips.manage");
fastify.get(
"/",
{
preHandler: requireAnyPermission(
"trips.manage",
"trips.record",
"trips.history",
),
},
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
// Admin role bypasses permission checks entirely (requireAnyPermission
// lets it through with an empty permissions list), so treat the admin
// role as a manager for scoping purposes too.
const isAdmin =
authData.roleName === "admin" ||
authData.permissions.includes("trips.manage");
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
if (query.month) {
const monthStr = String(query.month);
let yr: number, mo: number;
if (monthStr.includes("-")) {
// Combined YYYY-MM format
const [yStr, mStr] = monthStr.split("-");
yr = Number(yStr);
mo = Number(mStr);
} else if (query.year) {
yr = Number(query.year);
mo = Number(query.month);
} else {
yr = NaN;
mo = NaN;
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
if (query.month) {
const monthStr = String(query.month);
let yr: number, mo: number;
if (monthStr.includes("-")) {
// Combined YYYY-MM format
const [yStr, mStr] = monthStr.split("-");
yr = Number(yStr);
mo = Number(mStr);
} else if (query.year) {
yr = Number(query.year);
mo = Number(query.month);
} else {
yr = NaN;
mo = NaN;
}
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
// Use explicit date strings to avoid toJSON timezone shift
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
}
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
// Use explicit date strings to avoid toJSON timezone shift
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
}
const [trips, total] = await Promise.all([
prisma.trips.findMany({
where,
skip,
take: limit,
orderBy: { trip_date: order },
include: {
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
},
}),
prisma.trips.count({ where }),
]);
const [trips, total] = await Promise.all([
prisma.trips.findMany({
where,
skip,
take: limit,
orderBy: { trip_date: order },
include: {
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
},
}),
prisma.trips.count({ where }),
]);
return reply.send({
success: true,
data: trips,
pagination: buildPaginationMeta(total, page, limit),
});
});
return paginated(reply, trips, buildPaginationMeta(total, page, limit));
},
);
// GET /api/admin/trips/users — users with trips.record permission
fastify.get(
"/users",
{ preHandler: requireAuth },
{ preHandler: requireAnyPermission("trips.record", "trips.manage") },
async (_request, reply) => {
const users = await prisma.users.findMany({
where: {
@@ -212,54 +218,70 @@ export default async function tripsRoutes(
},
);
fastify.post("/", { preHandler: requireAuth }, async (request, reply) => {
const parsed = parseBody(CreateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authData = request.authData!;
fastify.post(
"/",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const parsed = parseBody(CreateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const authData = request.authData!;
const canRecord =
authData.permissions.includes("trips.manage") ||
authData.permissions.includes("trips.record");
if (!canRecord) return error(reply, "Nedostatečná oprávnění", 403);
if (body.end_km < body.start_km) {
return error(
reply,
"Konečný stav km nesmí být menší než počáteční",
400,
);
}
if (body.end_km < body.start_km) {
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
}
const vehicleId = Number(body.vehicle_id);
const trip = await prisma.trips.create({
data: {
vehicle_id: Number(body.vehicle_id),
user_id: body.user_id ? Number(body.user_id) : authData.userId,
trip_date: new Date(String(body.trip_date)),
start_km: Number(body.start_km),
end_km: Number(body.end_km),
route_from: String(body.route_from),
route_to: String(body.route_to),
is_business: !!body.is_business,
notes: body.notes ? String(body.notes) : null,
},
});
const trip = await prisma.trips.create({
data: {
vehicle_id: vehicleId,
user_id: body.user_id ? Number(body.user_id) : authData.userId,
trip_date: new Date(String(body.trip_date)),
start_km: Number(body.start_km),
end_km: Number(body.end_km),
route_from: String(body.route_from),
route_to: String(body.route_to),
is_business: !!body.is_business,
notes: body.notes ? String(body.notes) : null,
},
});
await prisma.vehicles.update({
where: { id: Number(body.vehicle_id) },
data: { actual_km: Number(body.end_km) },
});
// Recompute actual_km from MAX(end_km) across all trips (same as
// PUT/DELETE). Setting it unconditionally to this trip's end_km would
// regress the odometer when a back-dated trip with a lower end_km is
// entered after a later trip with a higher reading.
const maxTrip = await prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { end_km: "desc" },
select: { end_km: true },
});
if (maxTrip) {
await prisma.vehicles.update({
where: { id: vehicleId },
data: { actual_km: Number(maxTrip.end_km) },
});
}
await logAudit({
request,
authData,
action: "create",
entityType: "trip",
entityId: trip.id,
description: `Vytvořena jízda`,
});
return success(reply, { id: trip.id }, 201, "Jízda byla zaznamenána");
});
await logAudit({
request,
authData,
action: "create",
entityType: "trip",
entityId: trip.id,
description: `Vytvořena jízda`,
});
return success(reply, { id: trip.id }, 201, "Jízda byla zaznamenána");
},
);
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
if (id === null) return;
@@ -333,7 +355,7 @@ export default async function tripsRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
if (id === null) return;