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:
309
src/__tests__/attendance.test.ts
Normal file
309
src/__tests__/attendance.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
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 attendanceRoutes from "../routes/admin/attendance";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression tests for the attendance create/edit wire format.
|
||||
//
|
||||
// The admin create/edit forms send COMBINED local datetimes
|
||||
// ("YYYY-MM-DDTHH:MM:00", built by combineDatetime from a date + time input)
|
||||
// for arrival_time / departure_time / break_start / break_end, and the
|
||||
// service parses them with `new Date(...)`. Commit 519edce validated these
|
||||
// fields with `timeString` (bare HH:MM), which rejected every create/edit
|
||||
// containing a time, and CreateAttendanceSchema lacked break_start/break_end
|
||||
// entirely, so breaks were silently stripped on create. These tests pin the
|
||||
// combined-datetime contract at the HTTP route level.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// All fixtures live on far-future dates so they can never collide with real
|
||||
// rows in the throwaway test DB; cleanup deletes exactly this range.
|
||||
const RANGE_START = new Date("2098-01-01T00:00:00");
|
||||
const RANGE_END = new Date("2099-01-01T00:00:00");
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminUserId: number;
|
||||
let adminToken: string;
|
||||
|
||||
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(attendanceRoutes, { prefix: "/api/admin/attendance" });
|
||||
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 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,
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupFixtureRows() {
|
||||
await prisma.attendance.deleteMany({
|
||||
where: {
|
||||
user_id: adminUserId,
|
||||
shift_date: { gte: RANGE_START, lt: RANGE_END },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await cleanupFixtureRows();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupFixtureRows();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupFixtureRows();
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("POST /api/admin/attendance (combined datetimes)", () => {
|
||||
it("creates a work record with arrival+departure datetimes and persists them", async () => {
|
||||
const arrival = "2098-03-10T08:00:00";
|
||||
const departure = "2098-03-10T16:30:00";
|
||||
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-10",
|
||||
leave_type: "work",
|
||||
arrival_time: arrival,
|
||||
departure_time: departure,
|
||||
notes: "attendance_wire_test work",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec).toBeTruthy();
|
||||
expect(rec!.leave_type).toBe("work");
|
||||
expect(rec!.arrival_time?.getTime()).toBe(new Date(arrival).getTime());
|
||||
expect(rec!.departure_time?.getTime()).toBe(new Date(departure).getTime());
|
||||
});
|
||||
|
||||
it("persists break_start/break_end on create (previously silently stripped)", async () => {
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-11",
|
||||
leave_type: "work",
|
||||
arrival_time: "2098-03-11T08:00:00",
|
||||
departure_time: "2098-03-11T16:30:00",
|
||||
break_start: "2098-03-11T12:00:00",
|
||||
break_end: "2098-03-11T12:30:00",
|
||||
notes: "attendance_wire_test break",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.break_start?.getTime()).toBe(
|
||||
new Date("2098-03-11T12:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_end?.getTime()).toBe(
|
||||
new Date("2098-03-11T12:30:00").getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a leave record with NO time fields", async () => {
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-12",
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
notes: "attendance_wire_test leave",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("vacation");
|
||||
expect(Number(rec!.leave_hours)).toBe(8);
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
});
|
||||
|
||||
it('tolerates "" for unset datetime fields (old form payloads) → null', async () => {
|
||||
// The AttendanceCreate page historically always sent empty strings for
|
||||
// the unfilled time fields — even for leave records — which 400'd EVERY
|
||||
// submit after 519edce. The schema must treat "" as "not set".
|
||||
const res = await authPost("/api/admin/attendance", adminToken, {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2098-03-13",
|
||||
leave_type: "sick",
|
||||
leave_hours: 8,
|
||||
arrival_time: "",
|
||||
departure_time: "",
|
||||
break_start: "",
|
||||
break_end: "",
|
||||
notes: "attendance_wire_test empty strings",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: body.data.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("sick");
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
expect(rec!.break_start).toBeNull();
|
||||
expect(rec!.break_end).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
|
||||
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
|
||||
const created = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 2, 14, 12, 0, 0),
|
||||
leave_type: "work",
|
||||
arrival_time: new Date("2098-03-14T08:00:00"),
|
||||
departure_time: new Date("2098-03-14T16:30:00"),
|
||||
notes: "attendance_wire_test update",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await authPut(
|
||||
`/api/admin/attendance/${created.id}`,
|
||||
adminToken,
|
||||
{
|
||||
leave_type: "work",
|
||||
arrival_time: "2098-03-14T22:00:00",
|
||||
departure_time: "2098-03-15T06:00:00",
|
||||
break_start: "2098-03-15T02:00:00",
|
||||
break_end: "2098-03-15T02:30:00",
|
||||
notes: "attendance_wire_test updated",
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(rec!.arrival_time?.getTime()).toBe(
|
||||
new Date("2098-03-14T22:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.departure_time?.getTime()).toBe(
|
||||
new Date("2098-03-15T06:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_start?.getTime()).toBe(
|
||||
new Date("2098-03-15T02:00:00").getTime(),
|
||||
);
|
||||
expect(rec!.break_end?.getTime()).toBe(
|
||||
new Date("2098-03-15T02:30:00").getTime(),
|
||||
);
|
||||
expect(rec!.notes).toBe("attendance_wire_test updated");
|
||||
});
|
||||
|
||||
it("clears times when null is sent (leave-type edit)", async () => {
|
||||
const created = await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date(2098, 2, 16, 12, 0, 0),
|
||||
leave_type: "work",
|
||||
arrival_time: new Date("2098-03-16T08:00:00"),
|
||||
departure_time: new Date("2098-03-16T16:30:00"),
|
||||
notes: "attendance_wire_test to-leave",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await authPut(
|
||||
`/api/admin/attendance/${created.id}`,
|
||||
adminToken,
|
||||
{
|
||||
leave_type: "vacation",
|
||||
leave_hours: 8,
|
||||
arrival_time: null,
|
||||
departure_time: null,
|
||||
break_start: null,
|
||||
break_end: null,
|
||||
notes: "attendance_wire_test to-leave",
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
|
||||
const rec = await prisma.attendance.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(rec!.leave_type).toBe("vacation");
|
||||
expect(rec!.arrival_time).toBeNull();
|
||||
expect(rec!.departure_time).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
invoiceTotalWithVat,
|
||||
createInvoice,
|
||||
updateInvoice,
|
||||
} from "../services/invoices.service";
|
||||
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
|
||||
|
||||
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||
@@ -118,3 +124,58 @@ describe("invoiceTotalWithVat", () => {
|
||||
expect(invoiceTotalWithVat(inv)).toBe(968);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* PUT regression — billing_text must survive UpdateInvoiceSchema */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
|
||||
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
|
||||
// which STRIPS unknown keys — so a field missing from the schema is silently
|
||||
// dropped before the service ever sees it, while the client still gets a
|
||||
// success response. This block mirrors that exact flow against the real DB.
|
||||
|
||||
describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
|
||||
const invoiceIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
invoiceIds.length = 0;
|
||||
});
|
||||
|
||||
it("persists an edited billing_text (the schema must not strip it)", async () => {
|
||||
const draft = await createInvoice({ billing_text: "Původní text" });
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
// Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
|
||||
const parsed = UpdateInvoiceSchema.parse({
|
||||
billing_text: "Fakturujeme Vám dle objednávky č. 123",
|
||||
});
|
||||
expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
|
||||
|
||||
const res = await updateInvoice(draft.id, parsed);
|
||||
expect("error" in res).toBe(false);
|
||||
|
||||
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
|
||||
});
|
||||
|
||||
it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
|
||||
const draft = await createInvoice({ billing_text: "Text na PDF" });
|
||||
invoiceIds.push(draft.id);
|
||||
|
||||
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
|
||||
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" }));
|
||||
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBe("Text na PDF");
|
||||
|
||||
// Explicit null -> cleared (the strFields path maps falsy -> null).
|
||||
await updateInvoice(
|
||||
draft.id,
|
||||
UpdateInvoiceSchema.parse({ billing_text: null }),
|
||||
);
|
||||
row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||
expect(row?.billing_text).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } 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 { createOrder, listOrders } from "../services/orders.service";
|
||||
import { config } from "../config/env";
|
||||
import ordersRoutes from "../routes/admin/orders";
|
||||
import {
|
||||
createOrder,
|
||||
listOrders,
|
||||
getOrder,
|
||||
getOrderAttachment,
|
||||
} from "../services/orders.service";
|
||||
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
@@ -80,6 +91,166 @@ describe("listOrders search filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("order attachment payload hygiene", () => {
|
||||
// The PO attachment blob must be served ONLY by GET /:id/attachment —
|
||||
// list/detail payloads carry attachment_name as the existence signal.
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.4 orders-list-test-attachment-bytes");
|
||||
const TEST_ADMIN = "orders_list_test_admin";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildOrdersApp>>;
|
||||
let adminToken: string;
|
||||
let adminUserId: number;
|
||||
|
||||
async function buildOrdersApp() {
|
||||
const fastify = Fastify({ logger: false });
|
||||
await fastify.register(cookie);
|
||||
await fastify.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
// multipart is registered inside ordersRoutes itself
|
||||
await fastify.register(ordersRoutes, { prefix: "/api/admin/orders" });
|
||||
return fastify;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildOrdersApp();
|
||||
|
||||
// Clean up any leftover test user from a previous failed run
|
||||
await prisma.users.deleteMany({ where: { username: TEST_ADMIN } });
|
||||
|
||||
const adminRole = await prisma.roles.findFirst({
|
||||
where: { name: "admin" },
|
||||
});
|
||||
if (!adminRole)
|
||||
throw new Error("Admin role not found — seed the database first");
|
||||
|
||||
const adminUser = await prisma.users.create({
|
||||
data: {
|
||||
username: TEST_ADMIN,
|
||||
email: `${TEST_ADMIN}@test.local`,
|
||||
password_hash:
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||
first_name: "Orders",
|
||||
last_name: "ListTest",
|
||||
is_active: true,
|
||||
role_id: adminRole.id,
|
||||
},
|
||||
});
|
||||
adminUserId = adminUser.id;
|
||||
adminToken = jwt.sign(
|
||||
{ sub: adminUser.id, username: adminUser.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.users.deleteMany({ where: { id: adminUserId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
/** One order WITH a stored PO attachment, one WITHOUT. */
|
||||
async function makeOrderPair() {
|
||||
const customer = await makeCustomer();
|
||||
const withRes = await createOrder(
|
||||
{ ...baseOrder, customer_id: customer.id, create_project: false },
|
||||
PDF_BYTES,
|
||||
"po-test.pdf",
|
||||
);
|
||||
if (!("id" in withRes)) failResult(withRes);
|
||||
createdOrderIds.push(withRes.id!);
|
||||
|
||||
const withoutRes = await createOrder({
|
||||
...baseOrder,
|
||||
customer_id: customer.id,
|
||||
create_project: false,
|
||||
});
|
||||
if (!("id" in withoutRes)) failResult(withoutRes);
|
||||
createdOrderIds.push(withoutRes.id!);
|
||||
|
||||
return { withId: withRes.id!, withoutId: withoutRes.id! };
|
||||
}
|
||||
|
||||
it("listOrders rows never contain attachment_data; attachment_name signals existence", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const list = await listOrders(baseListParams);
|
||||
const withRow = list.data.find((o) => o.id === withId);
|
||||
const withoutRow = list.data.find((o) => o.id === withoutId);
|
||||
expect(withRow).toBeTruthy();
|
||||
expect(withoutRow).toBeTruthy();
|
||||
|
||||
expect("attachment_data" in withRow!).toBe(false);
|
||||
expect("attachment_data" in withoutRow!).toBe(false);
|
||||
expect(withRow!.attachment_name).toBe("po-test.pdf");
|
||||
expect(withoutRow!.attachment_name).toBeNull();
|
||||
});
|
||||
|
||||
it("getOrder detail never contains attachment_data but keeps attachment_name", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const withDetail = await getOrder(withId);
|
||||
const withoutDetail = await getOrder(withoutId);
|
||||
expect(withDetail).toBeTruthy();
|
||||
expect(withoutDetail).toBeTruthy();
|
||||
|
||||
expect("attachment_data" in withDetail!).toBe(false);
|
||||
expect("attachment_data" in withoutDetail!).toBe(false);
|
||||
expect(withDetail!.attachment_name).toBe("po-test.pdf");
|
||||
expect(withoutDetail!.attachment_name).toBeNull();
|
||||
});
|
||||
|
||||
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
const headers = { authorization: `Bearer ${adminToken}` };
|
||||
|
||||
const listRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/admin/orders?sort=id&order=desc&per_page=100",
|
||||
headers,
|
||||
});
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
const listJson = JSON.parse(listRes.body);
|
||||
expect(listJson.data.map((o: { id: number }) => o.id)).toContain(withId);
|
||||
expect(listRes.body).not.toContain("attachment_data");
|
||||
|
||||
const detailRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withId}`,
|
||||
headers,
|
||||
});
|
||||
expect(detailRes.statusCode).toBe(200);
|
||||
expect(detailRes.body).not.toContain("attachment_data");
|
||||
expect(JSON.parse(detailRes.body).data.attachment_name).toBe("po-test.pdf");
|
||||
|
||||
// The dedicated endpoint is the ONE place the blob is served — byte-exact.
|
||||
const attRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withId}/attachment`,
|
||||
headers,
|
||||
});
|
||||
expect(attRes.statusCode).toBe(200);
|
||||
expect(attRes.headers["content-type"]).toContain("application/pdf");
|
||||
expect(Buffer.from(attRes.rawPayload).equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
const noAttRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/orders/${withoutId}/attachment`,
|
||||
headers,
|
||||
});
|
||||
expect(noAttRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("getOrderAttachment round-trips the stored bytes (and null without one)", async () => {
|
||||
const { withId, withoutId } = await makeOrderPair();
|
||||
|
||||
const att = await getOrderAttachment(withId);
|
||||
expect(att).toBeTruthy();
|
||||
expect(att!.filename).toBe("po-test.pdf");
|
||||
expect(att!.data.equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
expect(await getOrderAttachment(withoutId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listOrders month filter", () => {
|
||||
it("returns only orders whose created_at is in the given month", async () => {
|
||||
const customer = await makeCustomer();
|
||||
|
||||
239
src/__tests__/totp-backup.test.ts
Normal file
239
src/__tests__/totp-backup.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import * as OTPAuthLib from "otpauth";
|
||||
import { buildApp, extractCookie } from "./helpers";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { encrypt } from "../utils/encryption";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
|
||||
// Fixture prefix so cleanup never touches real data.
|
||||
const N = "totp_backup_test_";
|
||||
const TEST_USERNAME = `${N}user`;
|
||||
|
||||
// Plaintext backup codes seeded for the fixture user (the enable route stores
|
||||
// bcrypt hashes of 8-char uppercase hex codes — mirror that format).
|
||||
const BACKUP_CODE_1 = "AAAA1111";
|
||||
const BACKUP_CODE_2 = "BBBB2222";
|
||||
|
||||
let testUserId: number;
|
||||
|
||||
/** Pull the raw Set-Cookie header line for a given cookie name (with attrs). */
|
||||
function rawSetCookie(res: { headers: Record<string, unknown> }, name: string) {
|
||||
const cookies = res.headers["set-cookie"];
|
||||
if (!cookies) return undefined;
|
||||
const arr = Array.isArray(cookies) ? cookies : [cookies];
|
||||
return arr.find((c) => typeof c === "string" && c.startsWith(`${name}=`)) as
|
||||
| string
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** Create a pending TOTP login token the same way /login does (sha256 hash). */
|
||||
async function issueLoginToken(userId: number): Promise<string> {
|
||||
const raw = crypto.randomBytes(32).toString("hex");
|
||||
const hash = crypto.createHash("sha256").update(raw).digest("hex");
|
||||
await prisma.totp_login_tokens.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
token_hash: hash,
|
||||
expires_at: new Date(Date.now() + 5 * 60_000),
|
||||
},
|
||||
});
|
||||
return raw;
|
||||
}
|
||||
|
||||
// /totp/backup-verify carries its own per-IP rate limit
|
||||
// (config.rateLimit.loginTotp, default 5/min) and this file legitimately makes
|
||||
// more attempts than that — give each request a distinct source IP.
|
||||
let ipCounter = 0;
|
||||
function postBackupVerify(payload: Record<string, unknown>) {
|
||||
ipCounter += 1;
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/totp/backup-verify",
|
||||
payload,
|
||||
remoteAddress: `10.99.0.${ipCounter}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function fixtureUser() {
|
||||
const user = await prisma.users.findUniqueOrThrow({
|
||||
where: { id: testUserId },
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Clean up any leftover fixture from a previous failed run.
|
||||
const leftovers = await prisma.users.findMany({
|
||||
where: { username: { startsWith: N } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (leftovers.length) {
|
||||
const ids = leftovers.map((u) => u.id);
|
||||
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: { in: ids } },
|
||||
});
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
}
|
||||
|
||||
const role = await prisma.roles.findFirst();
|
||||
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||
|
||||
const hashedCodes = [
|
||||
await bcrypt.hash(BACKUP_CODE_1, config.security.bcryptCost),
|
||||
await bcrypt.hash(BACKUP_CODE_2, config.security.bcryptCost),
|
||||
];
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: TEST_USERNAME,
|
||||
email: `${N}user@test.local`,
|
||||
password_hash: await bcrypt.hash(
|
||||
"irrelevant",
|
||||
config.security.bcryptCost,
|
||||
),
|
||||
first_name: "TotpBackup",
|
||||
last_name: "Test",
|
||||
is_active: true,
|
||||
totp_enabled: true,
|
||||
totp_secret: encrypt(new OTPAuthLib.Secret().base32),
|
||||
totp_backup_codes: JSON.stringify(hashedCodes),
|
||||
role_id: role.id,
|
||||
},
|
||||
});
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.refresh_tokens
|
||||
.deleteMany({ where: { user_id: testUserId } })
|
||||
.catch(() => {});
|
||||
await prisma.totp_login_tokens
|
||||
.deleteMany({ where: { user_id: testUserId } })
|
||||
.catch(() => {});
|
||||
await prisma.users
|
||||
.deleteMany({ where: { username: { startsWith: N } } })
|
||||
.catch(() => {});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("POST /api/admin/totp/backup-verify", () => {
|
||||
it("returns 400 for missing fields", async () => {
|
||||
const res = await postBackupVerify({});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 401 for an invalid login token", async () => {
|
||||
const res = await postBackupVerify({
|
||||
login_token: "not-a-real-token",
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong backup code without consuming the stored codes", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: "ZZZZ9999",
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
|
||||
// Both backup codes must still be stored — nothing consumed.
|
||||
const user = await fixtureUser();
|
||||
expect(JSON.parse(user.totp_backup_codes as string)).toHaveLength(2);
|
||||
|
||||
// Reset the failed-attempt counter so this test can't lock the fixture
|
||||
// user for later tests (max_login_attempts comes from system settings).
|
||||
await prisma.users.update({
|
||||
where: { id: testUserId },
|
||||
data: { failed_login_attempts: 0 },
|
||||
});
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: testUserId },
|
||||
});
|
||||
});
|
||||
|
||||
it("issues tokens for a valid login token + backup code and consumes both (single-use)", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.data.access_token).toBe("string");
|
||||
expect(body.data.access_token.length).toBeGreaterThan(0);
|
||||
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
|
||||
expect(body.data.user.userId).toBe(testUserId);
|
||||
|
||||
// Same login completion as the TOTP path: httpOnly refresh cookie set.
|
||||
const cookie = extractCookie(res, "refresh_token");
|
||||
expect(cookie).toBeTruthy();
|
||||
const raw = rawSetCookie(res, "refresh_token")!;
|
||||
expect(raw).toMatch(/HttpOnly/i);
|
||||
expect(raw).toMatch(/SameSite=Strict/i);
|
||||
|
||||
// The used backup code must be removed (single-use).
|
||||
const user = await fixtureUser();
|
||||
const remaining: string[] = JSON.parse(user.totp_backup_codes as string);
|
||||
expect(remaining).toHaveLength(1);
|
||||
|
||||
// The login token must be consumed: replaying it (even with the second,
|
||||
// still-valid backup code) is rejected.
|
||||
const replay = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_2,
|
||||
});
|
||||
expect(replay.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects reuse of an already-consumed backup code", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_1,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().success).toBe(false);
|
||||
|
||||
await prisma.users.update({
|
||||
where: { id: testUserId },
|
||||
data: { failed_login_attempts: 0 },
|
||||
});
|
||||
await prisma.totp_login_tokens.deleteMany({
|
||||
where: { user_id: testUserId },
|
||||
});
|
||||
});
|
||||
|
||||
it("honors remember_me with a long-lived refresh token (parity with /login/totp)", async () => {
|
||||
const loginToken = await issueLoginToken(testUserId);
|
||||
const res = await postBackupVerify({
|
||||
login_token: loginToken,
|
||||
backup_code: BACKUP_CODE_2,
|
||||
remember_me: true,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const raw = rawSetCookie(res, "refresh_token")!;
|
||||
expect(raw).toMatch(
|
||||
new RegExp(`Max-Age=${config.jwt.refreshTokenRememberExpiry}`, "i"),
|
||||
);
|
||||
|
||||
const refreshRow = await prisma.refresh_tokens.findFirst({
|
||||
where: { user_id: testUserId },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
expect(refreshRow?.remember_me).toBe(true);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
@@ -6,6 +15,7 @@ import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import warehouseRoutes from "../routes/admin/warehouse";
|
||||
import { nasInvoicesManager } from "../services/nas-financials-manager";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -879,6 +889,177 @@ describe("Receipt flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5b. Receipt attachment download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Receipt attachment download", () => {
|
||||
let receiptId: number;
|
||||
let otherReceiptId: number;
|
||||
let attachmentId: number;
|
||||
let diacriticAttachmentId: number;
|
||||
const fileBytes = Buffer.from("%PDF-1.4 fake attachment bytes", "utf8");
|
||||
const filePath = "Přijaté/2026/06/wh_test_priloha.pdf";
|
||||
const diacriticFileName = "příloha č. 1.pdf";
|
||||
|
||||
beforeAll(async () => {
|
||||
// Item + two receipts (attachment belongs to the first one only)
|
||||
const itemRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/items",
|
||||
headers: authHeader(adminToken),
|
||||
payload: { name: `${N}item_att_download`, unit: "ks" },
|
||||
});
|
||||
const itemId = itemRes.json().data.id;
|
||||
|
||||
const receiptRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/receipts",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
notes: `${N}att_download_a`,
|
||||
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
|
||||
},
|
||||
});
|
||||
receiptId = receiptRes.json().data.id;
|
||||
|
||||
const otherReceiptRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/warehouse/receipts",
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
notes: `${N}att_download_b`,
|
||||
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
|
||||
},
|
||||
});
|
||||
otherReceiptId = otherReceiptRes.json().data.id;
|
||||
|
||||
// Attachment row created directly — the upload endpoint needs a writable
|
||||
// NAS, which is not available in the test environment. The NAS read is
|
||||
// stubbed per-test on the singleton (same temp-dir/stub seam as the
|
||||
// nas-document-manager tests); the DB rows and route logic stay real.
|
||||
const attachment = await prisma.sklad_receipt_attachments.create({
|
||||
data: {
|
||||
receipt_id: receiptId,
|
||||
file_name: "wh_test_priloha.pdf",
|
||||
file_mime: "application/pdf",
|
||||
file_size: fileBytes.length,
|
||||
file_path: filePath,
|
||||
},
|
||||
});
|
||||
attachmentId = attachment.id;
|
||||
|
||||
// Diacritic filename — the norm for a Czech company. Node throws on
|
||||
// header values with chars > U+00FF, so this exercises the RFC 5987 path.
|
||||
const diacriticAttachment = await prisma.sklad_receipt_attachments.create({
|
||||
data: {
|
||||
receipt_id: receiptId,
|
||||
file_name: diacriticFileName,
|
||||
file_mime: "application/pdf",
|
||||
file_size: fileBytes.length,
|
||||
file_path: "Přijaté/2026/06/wh_test_priloha_diakritika.pdf",
|
||||
},
|
||||
});
|
||||
diacriticAttachmentId = diacriticAttachment.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("downloads an existing attachment with correct content-type and bytes", async () => {
|
||||
const readSpy = vi
|
||||
.spyOn(nasInvoicesManager, "readReceived")
|
||||
.mockReturnValue({ data: fileBytes, fileName: "wh_test_priloha.pdf" });
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("application/pdf");
|
||||
expect(res.headers["content-disposition"]).toBe(
|
||||
`attachment; filename="wh_test_priloha.pdf"; filename*=UTF-8''wh_test_priloha.pdf`,
|
||||
);
|
||||
expect(res.rawPayload.equals(fileBytes)).toBe(true);
|
||||
expect(readSpy).toHaveBeenCalledWith(filePath);
|
||||
});
|
||||
|
||||
it("downloads an attachment with a Czech diacritic filename (RFC 5987)", async () => {
|
||||
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue({
|
||||
data: fileBytes,
|
||||
fileName: diacriticFileName,
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${diacriticAttachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const disposition = res.headers["content-disposition"];
|
||||
// The real UTF-8 name travels percent-encoded in filename* (RFC 5987) …
|
||||
expect(disposition).toContain(
|
||||
`filename*=UTF-8''${encodeURIComponent(diacriticFileName)}`,
|
||||
);
|
||||
// … alongside an ASCII-only fallback, so the header never carries
|
||||
// chars > U+00FF (which Node's setHeader rejects → 500).
|
||||
expect(disposition).toBe(
|
||||
`attachment; filename="p__loha _. 1.pdf"; filename*=UTF-8''p%C5%99%C3%ADloha%20%C4%8D.%201.pdf`,
|
||||
);
|
||||
expect(res.rawPayload.equals(fileBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when the attachment belongs to a different receipt", async () => {
|
||||
const readSpy = vi.spyOn(nasInvoicesManager, "readReceived");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${otherReceiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
// Ownership is rejected before any NAS access
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 for a nonexistent attachment id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/99999999`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 when the file is missing on the NAS", async () => {
|
||||
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects download without warehouse.view permission (403)", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
headers: authHeader(noPermToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Issue flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user