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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user