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

View File

@@ -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
// ---------------------------------------------------------------------------