From e11765bf0e1cfbb72c359c913ea3d506a8ad4e36 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 12 Jun 2026 23:00:19 +0200 Subject: [PATCH] fix: resolve all 28 findings from the 2026-06-12 full audit (TDD-pinned) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical (data integrity): - warehouse inventory confirm: throw (not return) inside $transaction so a failed deficit line rolls back the surplus corrective receipt — retries no longer accumulate phantom stock - warehouse issue confirm: validate batches against the COMBINED quantity of all lines (duplicate FIFO-resolved lines drove batches negative) - attendance delete: restore vacation_used/sick_used for the deleted day (in-transaction, clamped at 0) High: - auth refresh: terminated sessions (replaced_at only) get a plain 401 — the theft branch (family revocation) now fires only on replaced_by_hash - POST /users strips role_id for non-admin callers (mirrors PUT guard) - issued-order transition flushes unsaved edits via the full save payload when dirty; server contract (items+status in one PUT) pinned - received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable) - received-invoice dates: nullableIsoDateString + NaN guard before NAS save (Czech-format dates corrupted month/year, orphaned NAS files) - leave approval skips Czech public holidays and books each calendar year's hours against its own balance (mirrors createLeave) Medium/Low (classes): - 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input 500ed at Prisma instead of a Czech 400) - FK pre-validation: projects update + warehouse receipts/issues return Czech 400s instead of P2003 500s - invoice PDF degrades gracefully when the CNB rate is unavailable (recap omitted instead of 500 + lost NAS archival) - date boundaries: local-day filters (warehouse lists/reports, audit-log), @db.Date coercion on invoice dates - plan updateEntry re-checks the per-cell cap (self-excluding) - {id} tiebreaks on customers/received-invoices/warehouse-items sorts; /items honors the client sort param - htmlToPdf relaunches once when the shared browser died mid-render - offer number release parses the year from the document number (cross-year finalize+delete left permanent sequence gaps) - trips/vehicles km fields integer-coerced; AI budget regated to settings.company|settings.system; Settings System tab no longer clobbers Firma numbering patterns; draft invoices hide the dead PDF button; dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64; audit-log + invoice month buckets day-shift fixes Docs: corrected the stale "Chromium has no CSS margin-box footers" claim (html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit report M3 withdrawn accordingly. ~65 new pinning tests; every finding reproduced RED against the real test DB before its fix. Suite: 58 files / 634 tests green. Co-Authored-By: Claude Fable 5 --- AUDIT-2026-06-12.md | 334 +++++++++++++++ CLAUDE.md | 7 +- src/__tests__/ai-budget-permission.test.ts | 137 ++++++ .../attendance-delete-balance.test.ts | 139 ++++++ src/__tests__/audit-log-date-filter.test.ts | 96 +++++ src/__tests__/html-to-pdf-retry.test.ts | 73 ++++ src/__tests__/invoice-date-coercion.test.ts | 91 ++++ src/__tests__/invoice-pdf-cnb-degrade.test.ts | 108 +++++ src/__tests__/issued-orders.test.ts | 34 ++ src/__tests__/leave-requests-holidays.test.ts | 202 +++++++++ src/__tests__/offer-number-release.test.ts | 100 +++++ src/__tests__/pagination-tiebreak.test.ts | 126 ++++++ src/__tests__/plan-update-cap.test.ts | 106 +++++ src/__tests__/projects-update-fk.test.ts | 76 ++++ src/__tests__/received-invoice-dates.test.ts | 115 +++++ src/__tests__/schema-caps.test.ts | 394 ++++++++++++++++++ src/__tests__/schema-nan.test.ts | 6 +- .../session-terminate-refresh.test.ts | 138 ++++++ src/__tests__/trips-vehicles-km-int.test.ts | 63 +++ src/__tests__/users-create-role-guard.test.ts | 165 ++++++++ src/__tests__/warehouse-date-filter.test.ts | 92 ++++ src/__tests__/warehouse-fk-400.test.ts | 206 +++++++++ .../warehouse-inventory-confirm.test.ts | 124 ++++++ src/__tests__/warehouse-issue-confirm.test.ts | 196 +++++++++ src/__tests__/warehouse-items-sort.test.ts | 97 +++++ .../components/dashboard/DashQuickActions.tsx | 6 +- src/admin/pages/InvoiceDetail.tsx | 39 +- src/admin/pages/Invoices.tsx | 4 + src/admin/pages/IssuedOrderDetail.tsx | 18 +- src/admin/pages/IssuedOrders.tsx | 9 + src/admin/pages/ReceivedInvoices.tsx | 42 +- src/admin/pages/Settings.tsx | 21 +- src/routes/admin/ai.ts | 10 +- src/routes/admin/audit-log.ts | 5 +- src/routes/admin/customers.ts | 5 +- src/routes/admin/invoices-pdf.ts | 100 +++-- src/routes/admin/leave-requests.ts | 120 +++--- src/routes/admin/received-invoices.ts | 23 +- src/routes/admin/users.ts | 7 +- src/routes/admin/warehouse.ts | 141 ++++++- src/schemas/auth.schema.ts | 5 +- src/schemas/common.ts | 20 + src/schemas/customers.schema.ts | 16 +- src/schemas/invoices.schema.ts | 53 +-- src/schemas/orders.schema.ts | 20 +- src/schemas/received-invoices.schema.ts | 28 +- src/schemas/trips.schema.ts | 20 +- src/schemas/vehicles.schema.ts | 11 +- src/schemas/warehouse.schema.ts | 8 +- src/services/attendance.service.ts | 57 ++- src/services/auth.ts | 28 +- src/services/numbering.service.ts | 51 ++- src/services/plan.service.ts | 18 + src/services/projects.service.ts | 32 ++ src/services/warehouse.service.ts | 54 ++- src/utils/html-to-pdf.ts | 37 +- 56 files changed, 3968 insertions(+), 265 deletions(-) create mode 100644 AUDIT-2026-06-12.md create mode 100644 src/__tests__/ai-budget-permission.test.ts create mode 100644 src/__tests__/attendance-delete-balance.test.ts create mode 100644 src/__tests__/audit-log-date-filter.test.ts create mode 100644 src/__tests__/html-to-pdf-retry.test.ts create mode 100644 src/__tests__/invoice-date-coercion.test.ts create mode 100644 src/__tests__/invoice-pdf-cnb-degrade.test.ts create mode 100644 src/__tests__/leave-requests-holidays.test.ts create mode 100644 src/__tests__/offer-number-release.test.ts create mode 100644 src/__tests__/pagination-tiebreak.test.ts create mode 100644 src/__tests__/plan-update-cap.test.ts create mode 100644 src/__tests__/projects-update-fk.test.ts create mode 100644 src/__tests__/received-invoice-dates.test.ts create mode 100644 src/__tests__/schema-caps.test.ts create mode 100644 src/__tests__/session-terminate-refresh.test.ts create mode 100644 src/__tests__/trips-vehicles-km-int.test.ts create mode 100644 src/__tests__/users-create-role-guard.test.ts create mode 100644 src/__tests__/warehouse-date-filter.test.ts create mode 100644 src/__tests__/warehouse-fk-400.test.ts create mode 100644 src/__tests__/warehouse-inventory-confirm.test.ts create mode 100644 src/__tests__/warehouse-issue-confirm.test.ts create mode 100644 src/__tests__/warehouse-items-sort.test.ts diff --git a/AUDIT-2026-06-12.md b/AUDIT-2026-06-12.md new file mode 100644 index 0000000..2baffc8 --- /dev/null +++ b/AUDIT-2026-06-12.md @@ -0,0 +1,334 @@ +# Codebase Audit — boha-app-ts — 2026-06-12 + +## Summary + +| Severity | Count | +| ------------ | ----: | +| **Critical** | 3 | +| **High** | 6 | +| **Medium** | 11 | +| **Low** | 8 | +| **Total** | 28 | + +> 2026-06-12: M3 (offer PDF footer) withdrawn as a false positive after user +> verification — see the struck section below. Counts updated 12→11 / 29→28. + +## Methodology + +- **Fan-out discovery:** 27 independent finder passes swept the codebase across domain modules (auth/sessions, attendance/leave, documents — offers/orders/issued-orders/invoices, warehouse, projects, trips/vehicles, plan, users/roles, AI/Odin, settings) and cross-cutting dimensions (Zod-cap-vs-DB-column alignment, date/timezone boundaries, pagination determinism, React Query cache invalidation, transaction atomicity, FK-error→4xx mapping, privilege boundaries). +- **Dedupe vs prior backlog:** candidate findings were de-duplicated against the existing audit backlog (`docs/codebase-audit-findings-2026-06-06.md`, `docs/cross-module-consistency-audit-2026-06-09.md`, `REVIEW_FINDINGS.md`). Already-fixed items (e.g. the 2026-06-06 sessions audit-logging finding) and already-tracked duplicates were dropped; only net-new defects survived. +- **3-lens adversarial verification:** every surviving finding was independently re-checked through three lenses — (1) **trace-to-refute** (follow the full code path end-to-end and try to break each step), (2) **convention veto** (confirm it genuinely VIOLATES a documented `CLAUDE.md` convention and is NOT one of the deliberate "looks-like-a-bug" decisions: local-time `toJSON`, local-noon `@db.Date` writes, lenient date schemas, net-only documents, gross `received_invoices.amount`, shared `orders.*` permission family), and (3) **executable repro** (a concrete, developer-runnable reproduction with observable wrong output). A finding ships only with three independent `real` verdicts. Findings the convention lens vetoed (e.g. one warehouse over-issue variant whose exact numbers are blocked by an upstream aggregate guard — kept only after a corrected, reachable repro was supplied) were re-scoped, not waved through. + +Order below is by severity, then by blast radius within a severity. + +--- + +## CRITICAL + +### C1. Deleting a vacation/sick attendance record never restores the leave balance — `vacation_used` stays permanently inflated + +- **Severity:** Critical +- **File:** `src/services/attendance.service.ts:1751` +- **Failure scenario:** `createLeave` increments `leave_balances.vacation_used`/`sick_used` when booking leave (lines 1304–1331), but `deleteAttendance` (1751–1772) only runs `prisma.attendance.delete` — it never decrements the balance. `getStatus` (line 246) and `getBalances` (line 530) derive `vacation_remaining = vacation_total - vacation_used` from the stored counter, so cancelling a booked leave by deleting its rows permanently removes entitlement the employee never actually took. The leave-request approval path (`leave-requests.ts`) has the identical asymmetric increment-on-approve / no-decrement-on-delete. +- **Repro:** + 1. As an admin (`attendance.manage`), `POST /api/admin/attendance?action=leave` for user 5, `date_from=2026-07-06`, `date_to=2026-07-10`, `leave_type=vacation`, `leave_hours=8` → creates 5 rows and adds 40h to `vacation_used`. + 2. Read balances: `vacation_used=40`, `vacation_remaining = total-40`. + 3. `DELETE /api/admin/attendance/:id` for each of the 5 rows. + 4. Read balances again: rows are gone but `vacation_used` is still 40 and `vacation_remaining` is still down 40h. Only a manual admin `handleBalances` reset fixes it. +- **Pinning test:** Book a 5-day vacation, assert `vacation_used` rose by 40h, delete all 5 attendance rows, then assert `vacation_used` returned to its pre-booking value (fails today: stays 40). + +### C2. `confirmIssue`: duplicate issue lines on the same batch pass per-line validation but decrement cumulatively, driving batch quantity negative (silent over-issue) + +- **Severity:** Critical +- **File:** `src/services/warehouse.service.ts:626` +- **Failure scenario:** The confirm path validates and decrements stock **per line**, re-reading the batch fresh each iteration with no cross-line running tally and no `if (newBatchQty < 0) throw` guard. Two issue lines for the same item that resolve to the same batch each pass the independent `batch.quantity >= line.quantity` check, then the decrement loop accumulates: line A leaves the batch at 3, line B re-reads 3 and writes `3-5 = -2`, flagging `is_consumed=true`. The negative/consumed batch drops out of `getItemTotalStock` (`is_consumed:false` filter), so the over-issue is silently hidden from all stock totals. No `(issue_id, batch_id)` unique constraint exists on `sklad_issue_lines`. +- **Repro (corrected — the single-batch case is blocked by the per-item aggregate guard in `validateIssueReservationRules`; use two batches so the per-item total check passes while one batch still goes negative):** + 1. Seed item X with two unconsumed batches B1(qty 8, older) and B2(qty 8). Total stock = 16. + 2. `POST /issues` with two lines, both `item_id=X`, no `batch_id`, each `quantity=5`. `selectFifoBatches` is called once per line against the live DB with nothing persisted, so both resolve to B1; the per-item availability check (16 < 10 is false) passes. Draft stores two lines both `batch_id=B1`. + 3. `POST /issues/:id/confirm` → 200. B1 validated 8≥5 twice (both see original 8), then decremented to 3, then to `-2` with `is_consumed=true`. + 4. 10 units issued from a batch that held 8; B1 persists at `quantity=-2`, drops from stock totals — 2 units leave with no accounting trail and FIFO is broken, with no error raised. +- **Pinning test:** Confirm an issue containing two same-item lines whose combined quantity exceeds a single resolved batch; assert the confirm is rejected (400) and no batch row is left with `quantity < 0`. + +### C3. `confirmInventorySession` swallows an in-transaction throw with `return` instead of `throw`, committing partial stock writes the route reports as failed + +- **Severity:** Critical +- **File:** `src/services/warehouse.service.ts:1275` +- **Failure scenario:** The function body is `return prisma.$transaction(async (tx) => { ... })` (line 1108). A single loop over `session.items` interleaves real writes with a throwing op: the surplus branch (diff>0) creates a corrective receipt, receipt line, batch, and upserts `sklad_item_locations`; a later deficit line makes `selectFifoBatches` throw. The catch at line 1275 **returns** `{ error, status: 400 }` from inside the transaction callback. In an interactive Prisma transaction, returning a value (rather than throwing) **commits** — so the surplus item's writes persist while the session→CONFIRMED update (line 1293) is skipped, leaving the session DRAFT. The route sees `"error" in result` and returns HTTP 400 ("nothing changed"). Re-confirming the still-DRAFT session re-runs the loop and re-creates the same surplus batch — repeatable phantom inventory and corrupted valuation on every retry. +- **Repro:** + 1. ItemA exists; ItemB has a location row `quantity=10` but fewer unconsumed batches (documented location/batch drift at lines 1244–1250 — also reproducible by consuming batches between session create and confirm). + 2. `POST /admin/inventory-sessions` with items ordered `[{ItemA surplus, actual_qty 5}, {ItemB deficit, actual_qty 0}]` (ItemA's line gets the lower id, processed first). + 3. `POST /admin/inventory-sessions//confirm` → HTTP 400 "Nedostatečné množství na skladě", BUT ItemA now has a permanent +5 corrective batch + bumped `item_location`, and the session is still DRAFT. + 4. Re-confirm → another +5 for ItemA, fails on ItemB again. Each retry adds +5 phantom stock while the API reports failure. +- **Pinning test:** Confirm an inventory session whose item list has a valid surplus line before an over-deficit line; assert the response is an error AND no `sklad_receipts`/`sklad_batches` rows were created (transaction rolled back) and the session is unchanged. + +--- + +## HIGH + +### H1. Terminating one session force-logs-out ALL of the user's sessions on the terminated device's next refresh + +- **Severity:** High +- **File:** `src/services/auth.ts:280` +- **Failure scenario:** Session termination (`sessions.ts:94-97` DELETE `/:id`, `sessions.ts:124-131` action=all, `profile.ts:98-101` password-change) sets ONLY `replaced_at` (no `replaced_by_hash`, row not deleted). Legitimate rotation (`auth.ts:315-318`) sets BOTH. The reuse-detection condition at `auth.ts:280-282` is `storedToken.replaced_at || storedToken.replaced_by_hash`, evaluated BEFORE the expiry check at line 292 — so a manually-terminated token presented on a later refresh is misclassified as theft and runs `tx.refresh_tokens.deleteMany({ where: { user_id } })` (line 285), wiping every session for that user, including the laptop the user is actively on, plus a false "reuse detected" warning. +- **Repro:** + 1. Log in from two devices → Session A (laptop), Session B (phone), both `replaced_at=null`. + 2. From the laptop, `DELETE /api/admin/sessions/` → B gets `replaced_at` set only. + 3. After B's access token expires (~15 min), the phone calls `POST /api/admin/refresh` with B's still-present cookie. + 4. `refreshAccessToken` hits the reuse branch (B's `replaced_at` truthy), deletes ALL of the user's tokens including A. Laptop is silently logged out on its next refresh. +- **Pinning test:** Create two refresh-token sessions, terminate one via the sessions DELETE route, present the terminated token to `refreshAccessToken`; assert the OTHER session's row still exists (fails today: it is deleted). + +### H2. `order_items.description` Zod cap (8000) far exceeds DB column `VarChar(500)` → Prisma 500 on long descriptions + +- **Severity:** High +- **File:** `src/schemas/orders.schema.ts:12` +- **Failure scenario:** `OrderItemSchema.description = z.string().max(8000)` but `order_items.description` is `@db.VarChar(500)` (`schema.prisma:327`). `createOrder`/`updateOrder` map the value straight into `tx.order_items.createMany` with no truncation. A 501–8000 char description passes Zod, overflows the column under MySQL strict mode (P2000), and `createOrder`'s catch only re-maps errors carrying a `status` property — so it surfaces as HTTP 500 "Interní chyba serveru" instead of a clean Czech 400, with the whole order transaction rolled back. +- **Repro:** `POST /api/admin/orders` (Content-Type application/json so the manual-JSON branch runs), `items:[{ description: "x".repeat(600), quantity:1, unit_price:100 }]` → HTTP 500 instead of 400; no order persisted. Same on `PUT /api/admin/orders/:id` for an order in `prijata`/`v_realizaci`. +- **Pinning test:** `parseBody(OrderItemSchema, { description: "x".repeat(600), ... })` should fail Zod with a 400-class error (fails today: passes, then 500s at Prisma). Fix: `.max(500)`. + +### H3. Issued-order status transition silently discards unsaved item/section edits and marks them clean + +- **Severity:** High +- **File:** `src/admin/pages/IssuedOrderDetail.tsx:524` +- **Failure scenario:** A `sent` issued order is both editable AND has valid transitions. `handleStatusChange` (line 516) sends ONLY `{ status: newStatus }`; the server's `replaceItems = editable && Array.isArray(body.items)` is false for a status-only payload, so edited items/sections are never persisted. Worse, line 524 calls `markClean({ form, items, sections })` with the in-memory UNSAVED edits, re-baselining the unsaved-changes guard so `isDirty` flips false and the `beforeunload` warning never fires. After the transition the order is `confirmed` → read-only, so the lost edits cannot be re-applied. (Contrast: `OfferDetail`/`InvoiceDetail` do NOT call `markClean` on transition.) +- **Repro:** Open a `sent` issued order with `orders.edit`, edit a line item / section / supplier (form dirty), click "Potvrdit" and confirm. The transition succeeds, the edits are silently dropped, and on refetch the items/sections revert to stored values with no warning. +- **Pinning test:** Render `IssuedOrderDetail` for a `sent` order, mutate an item, invoke a status transition; assert the PUT payload includes the edited `items` (or that the guard stays dirty / a warning is shown) — fails today. + +### H4. Received-invoices list is paginated server-side (25/page) but the UI sends no `page` param and renders no Pagination control — rows 26+ are permanently hidden + +- **Severity:** High +- **File:** `src/admin/pages/ReceivedInvoices.tsx:265` +- **Failure scenario:** `receivedInvoiceListOptions` hits `/received-invoices`, which applies `skip`/`take` with `parsePagination`'s default limit of 25. The component builds the query with no `page`/`perPage`, reads only `listQuery.data?.data` (the first 25 rows), and renders NO ``. The "Celkem" footer is sourced from the separate `/received-invoices/list-totals` over the FULL filtered set, so with 26+ rows the displayed total visibly disagrees with the on-screen rows. The sibling issued-invoices tab (`Invoices.tsx`) correctly uses `usePaginatedQuery` + ``. +- **Repro:** Seed 26 CZK received invoices in one month. `GET /received-invoices?month=6&year=2026` returns `data.length=25`, `pagination.total=26`; `GET /received-invoices/list-totals?...` returns 26000 CZK. In the UI (Faktury → Přijaté, June 2026) the table shows 25 rows summing to 25000 while the footer shows 26000 — and the 26th invoice is unreachable. +- **Pinning test:** With 26 received invoices in a month, assert the page requests page 2 (or renders a pager) and that all 26 are reachable — fails today. + +### H5. Odin invoice import: Czech-format dates silently corrupt month/year (wrong month or NaN) in `received_invoices` + +- **Severity:** High +- **File:** `src/admin/components/odin/InvoiceReviewCard.tsx:79` +- **Failure scenario:** The review card exposes "Datum vystavení"/"Datum splatnosti" as free-text fields; `OdinChat.saveInvoice` sends the raw string. The multipart schema validates `issue_date` only as `z.string().max(255).nullish()`, so it passes. The route derives `invoiceMonth = new Date(issue_date).getMonth()+1` / `getFullYear()` with no validity guard. `new Date("15.03.2026")` → Invalid Date → `NaN` (day>12); `new Date("12.6.2026")` → December 2026 (wrong month). NAS save (line 410) runs BEFORE the DB create (line 420), so for the NaN case Prisma rejects into the `UnsignedTinyInt`/`UnsignedSmallInt` columns → HTTP 500 with an orphaned NAS file; for day≤12 it silently files under the wrong month/year. +- **Repro:** In Odin, edit an extracted invoice's issue date to `15.03.2026` and Save → 500 + orphaned NAS file. Use `12.6.2026` → invoice silently filed under month 12. Equivalent: `POST /api/admin/received-invoices` (multipart) with `issue_date:"15.03.2026"`. +- **Pinning test:** `POST /received-invoices` with `issue_date="15.03.2026"` should return a 400 (or normalize) — fails today (500 + NaN write). Fix: use `isoDateString` coercion / validity guard. + +### H6. Leave-request approval charges vacation/sick balance for Czech public holidays that fall inside the range + +- **Severity:** High +- **File:** `src/routes/admin/leave-requests.ts:222` +- **Failure scenario:** Both the request-creation loop (lines 90–97) and the approval loop (lines 222–244) count business days with a weekend-only filter (`dow !== 0 && dow !== 6`); the file never imports `isHoliday`. So weekday Czech public holidays inside the range are counted as charged leave days, and approval increments `vacation_used`/`sick_used` by `totalBusinessDays * 8` including them. The sibling `attendance.service.createLeave` (line 1270) correctly skips `isHoliday(...)`, and `getBusinessDaysInMonth` excludes holidays from the work fund — so the deduction is a double charge for a day already paid/free. +- **Repro:** Employee `POST /api/admin/leave-requests` `{leave_type:"vacation", date_from:"2026-05-01", date_to:"2026-05-09"}` (1.5. and 8.5. are weekday holidays in 2026) → `total_hours=48`. Approver `PUT /.../{id}` `{status:"approved"}` → `vacation_used` grows by 48h instead of 32h; holiday dates are recorded as consumed vacation in `attendance`. +- **Pinning test:** Approve a vacation request spanning a weekday public holiday; assert `vacation_used` increased only by the non-holiday business hours (fails today: includes the holiday). + +--- + +## MEDIUM + +### M1. Invoice date fields accept arbitrary strings and write a day early to `@db.Date` when a datetime value is submitted + +- **Severity:** Medium +- **File:** `src/services/invoices.service.ts:533` +- **Failure scenario:** `CreateInvoiceSchema`/`UpdateInvoiceSchema` type `issue_date`/`due_date`/`tax_date`/`paid_date` as plain `z.string().max(255).nullish()` (not `isoDateString`). `createInvoice`/`updateInvoice` do `new Date(String(...))` straight into the `@db.Date` columns. A round-tripped API value like `"2025-03-15T00:00:00"` (the `toJSON` override emits local time, no `Z`) parses as local Prague → `2025-03-14 22:00 UTC` → Prisma truncates to `2025-03-14`. The invoice then also lands in the wrong month bucket in `getInvoiceStats`/`buildInvoiceWhere`. Every other `@db.Date`-writing module uses `isoDateString` (which slices the time off); invoices is the lone outlier. +- **Repro:** `POST /api/admin/invoices` with `issue_date:"2025-03-01T00:00:00"` → DB stores `2025-02-28`; `GET .../stats?month=3&year=2025` excludes it and inflates February. +- **Pinning test:** Create an invoice with `issue_date="2025-03-01T00:00:00"`; assert the stored date is `2025-03-01` (fails today: `2025-02-28`). Fix: `isoDateString.nullish()`. + +### M2. `order_items.unit` Zod cap (255) exceeds DB column `VarChar(20)` → Prisma 500 on long unit strings + +- **Severity:** Medium +- **File:** `src/schemas/orders.schema.ts:15` +- **Failure scenario:** `unit: z.string().max(255)` vs `order_items.unit @db.VarChar(20)` (`schema.prisma:330`). A 21–255 char unit passes Zod, overflows the column (P2000), and surfaces as 500 (the create catch only re-maps `status`-bearing errors; update has no try/catch). The sibling offers/issued-orders schemas correctly use `.max(20)`. +- **Repro:** `POST /api/admin/orders` (JSON) `items:[{ description:"Test", quantity:1, unit_price:100, unit:"abcdefghijklmnopqrstuvwxyz" }]` → HTTP 500 instead of 400. (The MUI form enforces `maxLength:20` client-side, so this is an API/non-browser vector.) +- **Pinning test:** `parseBody(OrderItemSchema, { unit: "x".repeat(21), ... })` should 400 at Zod — fails today. Fix: `.max(20)`. + +### ~~M3. Offer PDF page footer ("Strana X z Y") never renders~~ — WITHDRAWN 2026-06-12 (false positive) + +- **Status:** **Withdrawn** — user verified the footer DOES render in production. +- **Why the audit got it wrong:** the finding (and all three verification lenses) trusted the repo's own documentation — the `html-to-pdf.ts:68-74` comment and the CLAUDE.md line "Chromium has no CSS margin-box footers". That fact has been stale since **Chrome 131 (Nov 2024), which implemented CSS `@page` margin boxes**; the project runs Puppeteer 24.40 (bundled Chrome 146 locally) and prod renders with system Chromium 149, both far past 131, so the offer's `@bottom-center { counter(page) }` footer renders fine. No lens rendered an actual PDF — documentation poisoning, not a code bug. +- **Real follow-up (doc-only, Low):** correct the outdated claim in `html-to-pdf.ts` and CLAUDE.md so future work doesn't repeat this; the `footerTemplate` mechanism and the CSS margin-box mechanism are now BOTH valid, coexisting approaches. + +### M4. `customer_order_number` Zod cap (255) exceeds DB column `VarChar(100)` → Prisma 500 + +- **Severity:** Medium +- **File:** `src/schemas/orders.schema.ts:54` +- **Failure scenario:** `customer_order_number: z.string().max(255)` vs `orders.customer_order_number @db.VarChar(100)` (`schema.prisma:356`). A 101–255 char value passes Zod, overflows the column (P2000 under strict mode), and surfaces as 500 instead of 400 (no P2003/P2000 mapping; create catch only handles `status`-bearing errors). +- **Repro:** `POST /api/admin/orders` (JSON) `{ customer_order_number: "A".repeat(150), currency:"CZK", language:"cs", status:"prijata" }` → HTTP 500; no order created. Same on `PUT /api/admin/orders/:id`. +- **Pinning test:** `parseBody(CreateOrderSchema, { customer_order_number: "A".repeat(150), ... })` should 400 — fails today. Fix: `.max(100)` on both schemas. + +### M5. Foreign-currency invoice PDF returns HTTP 500 (and fails to archive) when the CNB rate lookup throws + +- **Severity:** Medium +- **File:** `src/routes/admin/invoices-pdf.ts:411` +- **Failure scenario:** For a EUR invoice, `const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0` is unguarded. When CNB is unreachable and no cached entry exists for the issue date, `getRate` throws "Nepodařilo se získat aktuální kurzy z ČNB", which propagates to the route's catch and returns the 500 error page — blocking BOTH preview and NAS archival, even though the rate only feeds the cosmetic CZK-conversion footnote. Sibling consumers (`received-invoices` stats, `ai-tools`) wrap `toCzk` and degrade per-row; the PDF path has no fallback. +- **Repro:** EUR invoice with a back-dated/never-fetched issue date, CNB unreachable → `GET /api/admin/invoices-pdf/:id` (preview) or `?save=1` (archive) returns 500; the `save=1` archival is silently swallowed client-side, leaving no NAS copy. +- **Pinning test:** Mock `getRate` to throw, render a foreign-currency invoice PDF; assert a non-error response (PDF rendered with the conversion line omitted) — fails today (500). + +### M6. `updateProject` sets FK fields with no existence check → FK violation 500 + +- **Severity:** Medium +- **File:** `src/services/projects.service.ts:115` +- **Failure scenario:** `updateProject` writes `customer_id`/`responsible_user_id`/`quotation_id`/`order_id` straight into `prisma.projects.update` with only int coercion and no `findUnique`. The FKs are `onDelete: Restrict`, so a non-existent id raises P2003 → generic 500. `createProject` validates `customer_id`/`responsible_user_id` and returns clean Czech 400s; the update path is the inconsistent gap. +- **Repro:** `PUT /api/admin/projects/1` `{ "customer_id": 999999 }` → HTTP 500 "Interní chyba serveru" instead of "Zákazník nenalezen" 400. Same for the other three FK fields. +- **Pinning test:** `PUT /projects/:id` with a non-existent `customer_id`; assert a 400 with a Czech "nenalezen" message — fails today (500). Fix: mirror `createProject`'s existence checks. + +### M7. Date-range filters/reports exclude the whole `date_to` day (`lte` against UTC-midnight) — inclusive end date silently drops same-day records + +- **Severity:** Medium +- **File:** `src/routes/admin/warehouse.ts:1038` +- **Failure scenario:** Receipts/issues filters and the project-consumption / movement-log reports build `created_at: { lte: new Date(date_to) }`. `new Date("2026-06-12")` parses as UTC midnight = 02:00 Prague; `created_at` is `@db.DateTime(0)` storing local-time instants, so a receipt at 09:00 Prague (07:00Z) is `> 00:00Z` and excluded. The entire requested end day disappears from filtered lists and report totals. +- **Repro:** Confirm a receipt today at 09:00 Prague. `GET /admin/warehouse/receipts?date_from=2026-06-12&date_to=2026-06-12` returns zero rows. Reports for a period ending 2026-06-12 under-report the last day. +- **Pinning test:** Create a same-day receipt at 09:00 local, filter `date_to` = that date; assert the receipt is returned — fails today. Fix: half-open `lt utcMidnightOfLocalDay(date_to + 1 day)`. + +### M8. POST/PUT receipts & issues 500 on nonexistent or deleted FK ids instead of a clean Czech 400 + +- **Severity:** Medium +- **File:** `src/routes/admin/warehouse.ts:1117` +- **Failure scenario:** The receipts/issues create+update handlers write rows directly with no FK-existence pre-check and no try/catch; schemas only int-coerce ids. A deleted/nonexistent `supplier_id`/`location_id`/`item_id`/`project_id`/`batch_id` raises P2003/P2025 → generic 500. The document modules (offers/orders/issued-orders) and `trips.ts` all pre-validate FK existence precisely to avoid this; warehouse omits the guard. +- **Repro:** `POST /receipts` `{ items:[{ item_id: 99999999, quantity:1, unit_price:10 }] }` → 500. `POST /issues` `{ project_id: 99999999, items:[{ item_id:, quantity:1 }] }` → 500. `POST /issues` with a valid item + nonexistent explicit `batch_id` → 500. +- **Pinning test:** `POST /receipts` with a nonexistent `item_id`; assert a 400 ("Položka nenalezena") — fails today (500). + +### M9. `updateEntry` never re-checks the per-cell cap, so expanding an entry's range pushes a day past `MAX_RECORDS_PER_CELL` and silently hides existing records + +- **Severity:** Medium +- **File:** `src/services/plan.service.ts:597` +- **Failure scenario:** `createEntry`/`bulkCreateEntries`/`createOverride` all guard the per-cell cap of 3 via `assertEntryCapAvailable`, but `updateEntry` does not. Expanding an entry's `date_from`/`date_to` onto a day that already holds 3 active entries succeeds, producing 4. `resolveCell`/`resolveGrid` cap display at 3 (newest-first), so the oldest entry silently vanishes from the grid while remaining active in the DB. +- **Repro:** Create 3 active entries on 2099-07-01 for user 5; create a 4th on 2099-07-02; `PATCH /plan/entries/<4th>` `{ date_from:"2099-07-01", date_to:"2099-07-01" }` → 200; the grid for that day now shows only 3 of the 4 entries. +- **Pinning test:** Fill a day to the cap, then `PATCH` another entry to overlap that day; assert the update is rejected with the cap error — fails today. + +### M10. `POST /users` does not strip `role_id` for non-admins — a `users.create` holder can grant any non-admin role + +- **Severity:** Medium +- **File:** `src/routes/admin/users.ts:63` +- **Failure scenario:** The POST handler forwards `role_id` straight to `createUser`; `createUser` only rejects the literal `admin` role. The PUT handler explicitly strips `role_id`/`is_active` for non-admin callers ("Privilege-escalation guard"), so create is an inconsistent escalation surface: a `users.create` holder can mint an account on any high-privilege non-admin role (e.g. one bundling `settings.roles` or `users.edit`) the caller itself lacks. Violates the documented "Role-management writes are admin-only and re-checked" rule. +- **Repro:** As account M with only `users.create`, `POST /api/admin/users` `{ ..., role_id: N }` where N is a non-admin role bundling `settings.roles`/`users.edit` → 201; the new account holds permissions M never had. +- **Pinning test:** As a non-admin `users.create` caller, POST a user with a privileged `role_id`; assert the created user's role is NOT the privileged one (role stripped, as on PUT) — fails today. + +### M11. `route_from`/`route_to` Zod cap (255) exceeds DB column `VarChar(100)` — 500 or silent truncation + +- **Severity:** Medium +- **File:** `src/schemas/trips.schema.ts:17` +- **Failure scenario:** `route_from`/`route_to` cap at `.max(255)` (create lines 17–18, update 29–30) vs `trips.route_from/route_to @db.VarChar(100)` (`schema.prisma:668-669`). The route writes `String(body.route_from)` directly. A 101–255 char route passes Zod, then either 500s (P2000 strict mode, no global Prisma mapping) or silently truncates to 100 chars (data loss). The frontend TextFields have no `maxLength`, so the UI triggers it too. +- **Repro:** `POST /api/admin/trips` with a 150-char `route_from` → 500 (or truncated 201). Same on `PUT`. +- **Pinning test:** `parseBody(CreateTripSchema, { route_from: "x".repeat(101), ... })` should 400 — fails today. Fix: `.max(100)`. + +### M12. Shared Puppeteer browser disconnecting mid-render fails concurrent PDF requests (no re-acquire/retry) + +- **Severity:** Medium +- **File:** `src/utils/html-to-pdf.ts:92` +- **Failure scenario:** `getBrowser()` returns the cached module-level browser after a point-in-time `connected` check; `htmlToPdf` immediately does `b.newPage()` with no re-check. Two concurrent renders capture the same handle; if Chromium crashes (OOM/killed) after the guard, both reject. A's finally nulls the shared handle out from under B. Both return 500 even though an immediate retry would relaunch and succeed — there is no re-acquire/retry wrapper. +- **Repro:** Fire two simultaneous `GET /api/admin/orders-pdf/5` (always renders, no NAS short-circuit), then `pkill -9 chrome` during the render window → both requests 500; the next request succeeds. +- **Pinning test:** Stub the cached browser so `connected` is true at guard time and false at `newPage` time; assert `htmlToPdf` re-acquires and resolves (or both concurrent calls succeed on retry) — fails today. + +--- + +## LOW + +### L1. Client-supplied TOTP secret up to 255 chars overflows `VarChar(255)` after encryption, yielding a 500 instead of a 400 + +- **Severity:** Low +- **File:** `src/schemas/auth.schema.ts:30` +- **Failure scenario:** `TotpEnableSchema.secret` caps at `.max(255)` on the plaintext, but the stored value is the AES-256-GCM ciphertext (`base64(IV[12] + ciphertext + tag[16])`), which exceeds 255 chars for any plaintext over ~164 chars. `users.totp_secret` is `@db.VarChar(255)`. The pre-write `totp.validate` does not bound secret length, and the attacker controls both `secret` and `code`, so a long secret reaches the write → P2000 → 500 (strict mode) or silent ciphertext truncation that permanently locks the account out. +- **Repro:** Authenticated, 2FA-not-enabled user: generate a 250-char base32 secret + matching TOTP code, `POST /api/admin/totp/enable` with the correct password → 500 (or corrupted secret). +- **Pinning test:** Enable TOTP with a 250-char secret; assert a 400 (not 500) — fails today. Fix: `.max(64)` (real secrets are 16–32 chars). + +### L2. Offer number sequence is never released on delete when created and finalized in different calendar years + +- **Severity:** Low +- **File:** `src/services/offers.service.ts:522` +- **Failure scenario:** `generateOfferNumber` keys on `new Date().getFullYear()` (finalize year), assigning e.g. `2026/NA/001`. `deleteOffer` derives the release year from `existing.created_at.getFullYear()` (2025) and calls `releaseOfferNumber(2025, "2026/NA/001")`; `releaseSequence` reconstructs the highest as `2025/NA/`, which never equals `2026/NA/001`, so the `deletedNumber === highestNumber` guard is false and the 2026 counter keeps a permanent gap. `invoices.service.ts` solves the same problem correctly by parsing the year out of the document number. +- **Repro:** Create a draft `2025-12-30`, finalize it `2026-01-02` → `2026/NA/001`. Delete it → next finalized 2026 offer is `2026/NA/002` (gap; no `2026/NA/001`). +- **Pinning test:** Create a draft in year Y, finalize+delete it in year Y+1; assert the Y+1 sequence counter was released — fails today. Fix: parse the year from the assigned number. + +### L3. `customers` list paginates with non-unique sort columns and no `id` tiebreak → unstable pagination + +- **Severity:** Low +- **File:** `src/routes/admin/customers.ts:54` +- **Failure scenario:** `orderBy: { [sortField]: order }` over allow-listed fields (`name`/`city`/`country`/`company_id`, all non-unique) with no `{ id }` tiebreak, used with offset pagination. Rows sharing a sort-key value that straddle a page boundary can reorder between the page-1 and page-2 queries, duplicating one and skipping another. Violates the documented determinism rule; siblings (`issued-orders.ts`, `trips.ts`) use compound `[..., { id }]` orderings. (The same defect exists at `received-invoices.ts:144`.) +- **Repro:** Seed customers with a shared `city` straddling a `limit=2` boundary; page through `?sort=city` and observe a customer appearing on two pages while another is skipped. +- **Pinning test:** Paginate a sort over duplicate-`city` customers; assert the concatenated pages contain every customer exactly once — fails today. Fix: `orderBy: [{ [sortField]: order }, { id: order }]`. + +### L4. Invoice month navigation does not reset the page index → switching to a sparser month shows an empty table + +- **Severity:** Low +- **File:** `src/admin/pages/Invoices.tsx:244` +- **Failure scenario:** `prevMonth`/`nextMonth` mutate `statsMonth`/`statsYear` but never `setPage(1)` (unlike the status/search handlers). The list query keys on `page` + month, and the server never clamps page to `total_pages`, so paging to page 3 of a busy month then switching to a 1-page month requests `?page=3&month=...` and gets an empty `data` array — empty table for a populated month, with the pager reading "3 of 1". +- **Repro:** Open a month with 60+ invoices, go to page 3, click the previous-month chevron to a month with a handful → empty "Zatím nejsou žádné faktury" table. API: `GET /api/admin/invoices?page=3&month=&year=` returns `data:[]` with non-zero `total`. +- **Pinning test (component):** With `page=3`, fire `nextMonth`; assert `page` resets to 1 — fails today. + +### L5. Issued-orders sub-list keeps its page index when the parent Orders page changes month → empty list for sparser months + +- **Severity:** Low +- **File:** `src/admin/pages/IssuedOrders.tsx:135` +- **Failure scenario:** `IssuedOrders` receives `month`/`year` as props and holds `page` in local state with no effect/key resetting it on prop change (the child is not keyed by month/year, so it doesn't remount). Paging to page 2 of a busy month then moving the parent to a sparse month requests page 2 of the new month → empty `data` → "Zatím žádné vydané objednávky." for a month that has orders on page 1. +- **Repro:** Objednávky → Vydané: navigate to a month with ≥26 issued orders, click page 2, then use the parent month chevron to a month with 1–25 orders → empty sub-list. API equivalent: `GET /api/admin/issued-orders?page=2&month=<1..25 orders>&year=` → `data:[]` with non-zero `total`. +- **Pinning test (component):** With `page=2`, change the `month` prop; assert `page` resets to 1 (or is clamped to `total_pages`) — fails today. + +### L6. Settings "System" tab save clobbers freshly-saved "Firma" numbering patterns with stale values + +- **Severity:** Low +- **File:** `src/admin/pages/Settings.tsx:297` +- **Failure scenario:** Both tabs edit the same singleton `company_settings` row via `PUT /company-settings`. `sysForm` carries `offer_number_pattern`/`order_number_pattern`/`invoice_number_pattern` (populated once, gated by a never-reset `sysFormInitialized` flag). After editing numbering in the Firma tab (which invalidates `["company-settings"]`), `sysForm` stays stale; saving the System tab sends the whole stale form, and the backend applies every `!== undefined` field, silently reverting the Firma-tab change. +- **Repro:** Init the System tab; in the Firma tab change `offer_number_pattern` and save; return to System and save any unrelated field → `offer_number_pattern` reverts to its pre-edit value, with a success toast. +- **Pinning test:** PUT the offer pattern, then PUT a stale sysForm carrying the old pattern; assert the offer pattern is NOT reverted (or that System-tab saves omit the numbering fields) — fails today. + +### L7. `start_km`/`end_km` accept decimals but DB columns are `Int` — non-integer odometer reading errors or truncates + +- **Severity:** Low +- **File:** `src/schemas/trips.schema.ts:15` +- **Failure scenario:** `start_km`/`end_km` use `nonNegativeNumberFromForm` (only `Number.isFinite && >=0`, no integer refine), but the columns are `Int`. A decimal passes Zod, reaches the `Int` column → Prisma validation 500 or MySQL truncation (corrupting the reading and derived distance, propagated into `vehicles.actual_km`). Same mismatch for `vehicles.initial_km`/`actual_km`. The UI's `parseInt` is used only for the distance widget, not the submitted payload, so the browser is vulnerable too. +- **Repro:** `POST /api/admin/trips` `{ vehicle_id, trip_date:"2098-01-15", start_km:100.5, end_km:1200.7, route_from:"A", route_to:"B" }` → 500 (or truncated values). +- **Pinning test:** `parseBody(CreateTripSchema, { start_km: 100.5, ... })` should 400 — fails today. Fix: `nonNegativeIntFromForm` for the four km fields. + +### L8. AI monthly budget control: severity-mismatched permission, date asymmetry, dead UI button, currency overflow, audit-log boundary (grouped low-severity cluster) + +These five independently-confirmed low-severity findings share the "minor blast radius / narrow trigger" profile. Each is real and pinnable. + +- **L8a. Company-wide AI monthly budget is mutable by any `ai.use` holder (not admin/settings).** `src/routes/admin/ai.ts:58`. `PUT /api/admin/ai/budget` is gated only by `requirePermission("ai.use")`, an ordinary grantable permission. Setting `budget_usd=0` makes `assertBudgetAvailable` return 402 for everyone (admins included) → company-wide AI DoS; setting `1000000` removes the cost cap. The parallel `company-settings` writes are gated behind `settings.company`/`settings.system`. + - **Repro:** As any `ai.use` holder, `PUT /api/admin/ai/budget {"budget_usd":0}` → all `ai/chat` and `extract-invoices` calls 402. + - **Pinning test:** Call `PUT /ai/budget` as a non-admin `ai.use`-only user; assert 403 — fails today. Fix: gate behind `settings.company`/admin. + +- **L8b. Audit-log `date_from` filter boundary shifted ~2h vs `date_to` (UTC vs local parsing).** `src/routes/admin/audit-log.ts:46`. `from = new Date(date_from)` parses as UTC midnight (02:00 Prague), while `to = new Date(date_to + "T23:59:59")` parses as local — so events between 00:00 and 02:00 Prague on the from-date are silently excluded. + - **Repro:** `GET /api/admin/audit-log?date_from=2026-06-12&date_to=2026-06-12` omits a row logged at 01:30 Prague that day. + - **Pinning test:** Insert an audit row at 01:30 local, filter for that day; assert it's returned — fails today. + +- **L8c. Draft invoice shows a non-functional "Zobrazit fakturu" PDF button that always errors.** `src/admin/pages/InvoiceDetail.tsx:1805`. The button render guard lacks the `status !== "draft"` check that `OfferDetail`/`IssuedOrderDetail` have; clicking it on a draft opens a blank tab, 404s `/file`, closes the tab, and toasts an error. + - **Repro:** Open a draft invoice, click "Zobrazit fakturu" → blank tab flash + "PDF soubor nenalezen" toast. + - **Pinning test:** Render `InvoiceDetail` for a draft; assert the PDF button is not rendered — fails today. Fix: add `&& invoice.invoice_number` (or non-draft) to the guard. + +- **L8d. Odin "Měna" / received-invoice currency over 3 chars 500s the save (`VarChar(3)` vs schema `max(20)`).** `src/components/odin/InvoiceReviewCard.tsx:67` / `src/schemas/received-invoices.schema.ts:11`. A free-text currency like "Koruna" passes Zod (`max(20)`) but overflows `received_invoices.currency @db.VarChar(3)` (P2000 → 500), AND because the NAS write precedes the DB create, the uploaded file is orphaned. The same module's `description` (`max(8000)` vs `VarChar(500)`) and `invoice_number` (`max(255)` vs `VarChar(100)`) are also misaligned. + - **Repro:** Save an Odin invoice with `currency:"Koruna"` → 500 + orphaned NAS file. Equivalent: `POST /received-invoices` multipart with `currency:"Koruna"`. + - **Pinning test:** `parseBody(CreateReceivedInvoiceSchema, { currency:"Koruna", ... })` should 400 — fails today. Fix: `currency .max(3)`, `description .max(500)`, `invoice_number .max(100)`. + +- **L8e. Warehouse supplier `ico`/`dic` Zod caps (255) exceed `VarChar(20)` columns.** `src/schemas/warehouse.schema.ts:34`. A >20-char `ico`/`dic` (bad paste) passes Zod and 500s at Prisma. Low realism (real IČO=8 digits) but a genuine 500-vs-400 defect; the customers schema (`company_id`/`vat_id`) shares it. + - **Repro:** `POST /api/admin/warehouse/suppliers` `{ name:"Test", ico:"123456789012345678901" }` → 500. + - **Pinning test:** `parseBody(CreateSupplierSchema, { ico:"x".repeat(21), ... })` should 400 — fails today. Fix: `.max(20)`. + +> **Adjacent uncited Zod-cap mismatches confirmed during verification** (same bug class, fix alongside the above so the gap is closed once): invoice line-item `description` (`max(8000)` vs `VarChar(500)`) and `unit` (`max(255)` vs `VarChar(20)`) — `src/schemas/invoices.schema.ts:13,16`, **High** blast radius (whole invoice save rolls back); invoice bank/payment fields `payment_method`/`constant_symbol`/`bank_swift`/`bank_iban`/`bank_account` (caps 255 vs `VarChar(50)/VarChar(20)`) — `src/schemas/invoices.schema.ts:30-35`, **Medium**; warehouse `/items` list ignores the client `sort` param and sorts by non-unique `name` with no `id` tiebreak — `src/routes/admin/warehouse.ts:662`, **Low**; year-spanning leave request books all hours against the start year's balance only — `src/routes/admin/leave-requests.ts:195`, **Medium**; Dashboard "Přidat jízdu" quick action omits `["vehicles"]` invalidation — `src/admin/components/dashboard/DashQuickActions.tsx:184`, **Low**. These were verified `real` but fall outside the 29-item cited set; treat them as a fast-follow batch. + +--- + +## Recommended Fix Order (dependencies considered) + +**Phase 0 — Data-integrity criticals first (financial/stock corruption, silent & cumulative).** These corrupt persisted state and worsen on retry; fix before anything cosmetic. + +1. **C3** `confirmInventorySession` `return`→`throw` (one-line change; stops repeatable phantom inventory). Trivial, highest leverage. +2. **C2** `confirmIssue` cross-line over-issue guard (add a running per-batch tally / up-front availability pass, mirroring the existing `confirmIssue` validation-first pattern; consider a `(issue_id, batch_id)` unique constraint as a backstop). +3. **C1** leave-balance restore on attendance delete — but **do C1 together with H6 and the year-spanning leave finding**, since all three live in the leave/attendance balance code and a single corrected helper (per-day, holiday-aware, per-year accumulation, with an inverse on delete) resolves them coherently. Fix the holiday-counting (H6) and per-year split first, then route delete through the same helper. + +**Phase 1 — Security / privilege & session correctness.** 4. **H1** session-reuse misclassification (gate the reuse branch on `replaced_by_hash` alone; reorder vs the expiry check). Self-contained; high user impact. 5. **M10** `POST /users` role-strip for non-admins; **L8a** AI budget permission gate. Both are permission-boundary one-liners. + +**Phase 2 — The Zod-cap-vs-DB-column batch (single coordinated sweep).** All of **H2, M2, M4, M11, L1, L8d, L8e** plus the adjacent invoice item/bank caps share one mechanism and one fix shape (`.max()` alignment + optionally a global P2000→400 mapping in `server.ts`). Do them in one pass with a shared "cap-must-fit-column" test helper so the class is closed, not whacked one at a time. Highest blast radius within the batch (whole-save rollback): invoice item caps, then H2. + +> **Phase 2 follow-ups (2026-06-12, deliberately deferred):** (1) the optional +> global P2000→400 mapping in the `server.ts` error handler was NOT added — +> `server.ts` exports no app builder, so the mapping would be untestable +> without first extracting an exported `buildApp()`; do the refactor + backstop +> together. (2) Frontend `maxLength` attributes on the trips (odkud/kam) and +> invoice bank/payment form fields — pure UX polish now that Zod 400s +> over-length input with a Czech message; add when next touching those forms. + +**Phase 3 — FK-existence & error-mapping consistency.** **M6** (projects update), **M8** (warehouse receipts/issues), **M5** (CNB graceful degradation). These align the lagging modules with the established offers/orders pre-validation pattern; a shared P2003→Czech-400 mapping (added in Phase 2 if you choose the global-handler route) reduces the per-site work. + +**Phase 4 — Date/timezone boundaries.** **M1** (invoice date `isoDateString`), **M7** (warehouse `date_to` half-open), **H5** (Odin Czech-date), **L8b** (audit-log `from`). Group because they share the documented date-boundary helpers (`isoDateString`, `utcMidnightOfLocalDay`); fixing M1/H5 also removes a day-shift class. + +**Phase 5 — Pagination determinism & frontend state.** **H4** (received-invoices pager — add the missing `usePaginatedQuery`/``), **L3** (customers + received-invoices `id` tiebreak), **L4/L5** (page-reset on month change), **M9** (plan update cap), **H3** (issued-order transition flush edits + don't `markClean` stale state). H4 is the largest UI change; the rest are small, independent guards. + +**Phase 6 — Cosmetic / low-blast cleanup (parallelizable, no dependencies).** ~~M3~~ (withdrawn; only the stale Chromium-footer comment in `html-to-pdf.ts`/CLAUDE.md remains as a doc fix), **M12** (Puppeteer re-acquire/retry), **L2** (offer number release year), **L6** (Settings System/Firma clobber), **L7** (km integer coercion), **L8c** (draft PDF button guard), warehouse `/items` sort param + tiebreak, and the Dashboard `["vehicles"]` invalidation. + +Rationale for ordering: persisted-data corruption (Phase 0) cannot be allowed to accumulate; security boundaries (Phase 1) are cheap and high-impact; the cap batch (Phase 2) and FK/error mapping (Phase 3) are most efficient done as coordinated sweeps that share a fix shape and tests; date boundaries (Phase 4) share helpers; UI/pagination (Phase 5) depend on nothing earlier but benefit from the cleaner backend; everything in Phase 6 is independent and can be parallelized across contributors. diff --git a/CLAUDE.md b/CLAUDE.md index d515ec1..ea13af2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,8 +204,11 @@ descriptions for unnumbered drafts. Audit failures are non-fatal. a deterministic path and **overwrite in place** — never uniquePath `_N` suffixes. The issued-order PDF gets language from the document's `language` column and carries a Puppeteer `footerTemplate` footer on every page - ("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no - CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`. + ("Vystavil" left, "Strana X z Y"/"Page X of Y" centered). Two repeating- + footer mechanisms coexist and BOTH work: Puppeteer `footerTemplate` + (invoices + issued orders — `htmlToPdf(html, { footerTemplate })`) and CSS + `@page` margin boxes (`@bottom-center` + `counter(page)`, offer PDF — + supported since Chrome 131, Nov 2024). Don't migrate one to the other. - **Shared modules — extend, never fork local copies:** `src/admin/components/document/` (DocumentItemsEditor, SectionsEditor, LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` / diff --git a/src/__tests__/ai-budget-permission.test.ts b/src/__tests__/ai-budget-permission.test.ts new file mode 100644 index 0000000..f461c27 --- /dev/null +++ b/src/__tests__/ai-budget-permission.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, 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 { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import aiRoutes from "../routes/admin/ai"; +import { getBudgetUsd, setBudgetUsd } from "../services/ai.service"; + +/** + * Pinning test for audit finding L8a: the company-wide AI monthly budget must + * NOT be mutable by an ordinary ai.use holder (budget_usd=0 is a company-wide + * AI DoS; a huge value removes the cost cap). It is a company setting — + * settings.company / settings.system / admin only. + */ + +const N = "ai_budget_perm_"; +const stamp = Date.now().toString(36); + +let app: ReturnType; +let adminToken: string; +let aiUserToken: string; +let aiRoleId: number; +let savedBudget: number; + +function token(user: { id: number; username: string; roleName: string }) { + return jwt.sign( + { sub: user.id, username: user.username, role: user.roleName }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +} + +async function cleanup() { + await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); + const roles = await prisma.roles.findMany({ + where: { name: { startsWith: N } }, + select: { id: true }, + }); + const roleIds = roles.map((r) => r.id); + if (roleIds.length > 0) { + await prisma.role_permissions.deleteMany({ + where: { role_id: { in: roleIds } }, + }); + await prisma.roles.deleteMany({ where: { id: { in: roleIds } } }); + } +} + +beforeAll(async () => { + await cleanup(); + savedBudget = await getBudgetUsd(); + + app = Fastify({ logger: false }); + await app.register(cookie); + await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + app.addHook("onRequest", securityHeaders); + await app.register(aiRoutes, { prefix: "/api/admin/ai" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = token({ + id: admin.id, + username: admin.username, + roleName: "admin", + }); + + // Ordinary ai.use holder — may chat, must NOT control the budget. + const role = await prisma.roles.create({ + data: { name: `${N}aiuse_${stamp}`, display_name: "AI Use Only" }, + }); + aiRoleId = role.id; + const perm = await prisma.permissions.findFirst({ + where: { name: "ai.use" }, + }); + if (!perm) throw new Error("Test setup: ai.use permission missing"); + await prisma.role_permissions.create({ + data: { role_id: aiRoleId, permission_id: perm.id }, + }); + const user = await prisma.users.create({ + data: { + username: `${N}user_${stamp}`, + email: `${N}${stamp}@test.local`, + password_hash: + "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", + first_name: "AiUse", + last_name: "Only", + is_active: true, + role_id: aiRoleId, + }, + }); + aiUserToken = token({ + id: user.id, + username: user.username, + roleName: role.name, + }); +}); + +afterAll(async () => { + await setBudgetUsd(savedBudget); // restore whatever the test DB had + if (app) await app.close(); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("PUT /ai/budget permission gate (audit L8a)", () => { + it("rejects an ordinary ai.use holder with 403", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/admin/ai/budget", + headers: { + Authorization: `Bearer ${aiUserToken}`, + "Content-Type": "application/json", + }, + payload: { budget_usd: 0 }, + }); + expect(res.statusCode).toBe(403); + // ...and the budget must be untouched. + expect(await getBudgetUsd()).toBe(savedBudget); + }); + + it("allows an admin to set the budget", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/admin/ai/budget", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { budget_usd: savedBudget }, + }); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/src/__tests__/attendance-delete-balance.test.ts b/src/__tests__/attendance-delete-balance.test.ts new file mode 100644 index 0000000..7352c3d --- /dev/null +++ b/src/__tests__/attendance-delete-balance.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import prisma from "../config/database"; +import { createLeave, deleteAttendance } from "../services/attendance.service"; +import { localDateStr } from "../utils/date"; + +/** + * Pinning tests for audit finding C1: deleting a vacation/sick attendance + * record must restore the hours it charged to leave_balances. Without the + * restore, cancelling booked leave by deleting its rows permanently inflates + * vacation_used/sick_used. + */ + +const N = "att_delbal_"; + +let userId: number; + +/** First Monday of March in the given year — always before the earliest + * possible Easter holiday and clear of all fixed Czech holidays, so a + * Mon-Fri range books exactly 5 days. (Far-future year per fixture hygiene.) */ +function firstMondayOfMarch(year: number): Date { + const d = new Date(year, 2, 1, 12, 0, 0); + while (d.getDay() !== 1) d.setDate(d.getDate() + 1); + return d; +} + +async function cleanup() { + const users = await prisma.users.findMany({ + where: { username: { startsWith: N } }, + select: { id: true }, + }); + const ids = users.map((u) => u.id); + if (ids.length > 0) { + await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } }); + await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } }); + await prisma.users.deleteMany({ where: { id: { in: ids } } }); + } +} + +beforeAll(async () => { + await cleanup(); + const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } }); + if (!adminRole) + throw new Error("Admin role not found — seed the database first"); + const user = await prisma.users.create({ + data: { + username: `${N}user`, + email: `${N}user@test.local`, + password_hash: + "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", + first_name: "Delete", + last_name: "Balance", + is_active: true, + role_id: adminRole.id, + }, + }); + userId = user.id; +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +async function balance(year: number) { + return prisma.leave_balances.findFirst({ where: { user_id: userId, year } }); +} + +describe("deleteAttendance leave-balance restore (audit C1)", () => { + it("restores vacation_used when booked vacation rows are deleted", async () => { + const monday = firstMondayOfMarch(2098); + const friday = new Date(monday); + friday.setDate(friday.getDate() + 4); + + const created = await createLeave( + { + user_id: userId, + date_from: localDateStr(monday), + date_to: localDateStr(friday), + leave_type: "vacation", + leave_hours: 8, + }, + userId, + ); + expect("created" in created && created.created).toBe(5); + expect(Number((await balance(2098))?.vacation_used)).toBe(40); + + const rows = await prisma.attendance.findMany({ + where: { user_id: userId, leave_type: "vacation" }, + }); + expect(rows).toHaveLength(5); + for (const row of rows) { + const res = await deleteAttendance(row.id); + expect("success" in res && res.success).toBe(true); + } + + expect(Number((await balance(2098))?.vacation_used)).toBe(0); + }); + + it("restores sick_used when a booked sick day is deleted", async () => { + const monday = firstMondayOfMarch(2099); + await createLeave( + { + user_id: userId, + date_from: localDateStr(monday), + leave_type: "sick", + leave_hours: 8, + }, + userId, + ); + expect(Number((await balance(2099))?.sick_used)).toBe(8); + + const row = await prisma.attendance.findFirst({ + where: { user_id: userId, leave_type: "sick" }, + }); + expect(row).not.toBeNull(); + await deleteAttendance(row!.id); + + expect(Number((await balance(2099))?.sick_used)).toBe(0); + }); + + it("does not touch balances when deleting a plain work shift", async () => { + const wednesday = firstMondayOfMarch(2098); + wednesday.setDate(wednesday.getDate() + 9); // a weekday a week later + const shift = await prisma.attendance.create({ + data: { + user_id: userId, + shift_date: wednesday, + leave_type: "work", + }, + }); + + const before = await balance(2098); + await deleteAttendance(shift.id); + const after = await balance(2098); + + expect(Number(after?.vacation_used)).toBe(Number(before?.vacation_used)); + expect(Number(after?.sick_used)).toBe(Number(before?.sick_used)); + }); +}); diff --git a/src/__tests__/audit-log-date-filter.test.ts b/src/__tests__/audit-log-date-filter.test.ts new file mode 100644 index 0000000..a859475 --- /dev/null +++ b/src/__tests__/audit-log-date-filter.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import auditLogRoutes from "../routes/admin/audit-log"; + +/** + * Pinning test for audit finding L8b: the audit-log date_from boundary must + * be the LOCAL midnight of the requested day, symmetric with the date_to + * side (which already parses "T23:59:59" as local). The old + * `new Date(date_from)` was UTC midnight = 01:00/02:00 Prague, silently + * excluding events logged in the first morning hours of the from-day. + * + * NOTE: audit_logs.created_at is @db.Timestamp(0) — capped at 2038, so the + * far-future fixture year is 2037, not 2098. + */ + +const N = "auditlog_datefilter_"; +const DAY = "2037-04-09"; + +let app: ReturnType; +let adminToken: string; +let earlyId: number; // 00:30 local — inside the old excluded window +let middayId: number; // 12:00 local +let prevDayId: number; // 23:30 local the day BEFORE — must stay excluded + +async function cleanup() { + await prisma.audit_logs.deleteMany({ + where: { description: { contains: N } }, + }); +} + +beforeAll(async () => { + await cleanup(); + + app = Fastify({ logger: false }); + await app.register(auditLogRoutes, { prefix: "/api/admin/audit-log" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); + + const early = await prisma.audit_logs.create({ + data: { + action: "test", + description: `${N}early`, + created_at: new Date(2037, 3, 9, 0, 30, 0), + }, + }); + earlyId = early.id; + const midday = await prisma.audit_logs.create({ + data: { + action: "test", + description: `${N}midday`, + created_at: new Date(2037, 3, 9, 12, 0, 0), + }, + }); + middayId = midday.id; + const prevDay = await prisma.audit_logs.create({ + data: { + action: "test", + description: `${N}prevday`, + created_at: new Date(2037, 3, 8, 23, 30, 0), + }, + }); + prevDayId = prevDay.id; +}); + +afterAll(async () => { + if (app) await app.close(); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("audit-log date_from boundary (audit L8b)", () => { + it("includes events from the first morning hours of the from-day", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/admin/audit-log?date_from=${DAY}&date_to=${DAY}&limit=100`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id); + + expect(ids).toContain(earlyId); // 00:30 — dropped by the old UTC from-bound + expect(ids).toContain(middayId); + expect(ids).not.toContain(prevDayId); // previous evening stays out + }); +}); diff --git a/src/__tests__/html-to-pdf-retry.test.ts b/src/__tests__/html-to-pdf-retry.test.ts new file mode 100644 index 0000000..f7c0c8a --- /dev/null +++ b/src/__tests__/html-to-pdf-retry.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Pinning tests for audit finding M12: a shared Puppeteer browser that + * disconnects between getBrowser()'s point-in-time `connected` check and the + * actual render must be re-acquired once — instead of failing the request + * with a 500 that an immediate retry would have served fine. + */ + +const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn() })); + +vi.mock("puppeteer", () => ({ default: { launch: launchMock } })); + +function healthyBrowser(pdfBytes = new Uint8Array([1, 2, 3])) { + return { + connected: true, + newPage: vi.fn(async () => ({ + setContent: vi.fn(async () => undefined), + pdf: vi.fn(async () => pdfBytes), + close: vi.fn(async () => undefined), + })), + close: vi.fn(async () => undefined), + }; +} + +/** Browser whose connection dies the moment a page is requested — the + * post-guard crash window from the finding. */ +function dyingBrowser() { + const b = { + connected: true, + newPage: vi.fn(async () => { + b.connected = false; + throw new Error("Protocol error (Target.createTarget): Session closed."); + }), + close: vi.fn(async () => undefined), + }; + return b; +} + +beforeEach(async () => { + vi.resetModules(); + launchMock.mockReset(); +}); + +describe("htmlToPdf browser re-acquire (audit M12)", () => { + it("relaunches once and resolves when the cached browser died mid-acquire", async () => { + launchMock + .mockResolvedValueOnce(dyingBrowser()) + .mockResolvedValueOnce(healthyBrowser()); + + const { htmlToPdf } = await import("../utils/html-to-pdf"); + const pdf = await htmlToPdf("retry"); + + expect(Buffer.isBuffer(pdf)).toBe(true); + expect(launchMock).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry a genuine render error on a healthy browser", async () => { + const browser = healthyBrowser(); + browser.newPage = vi.fn(async () => ({ + setContent: vi.fn(async () => { + throw new Error("Render timeout"); + }), + pdf: vi.fn(async () => new Uint8Array()), + close: vi.fn(async () => undefined), + })); + launchMock.mockResolvedValue(browser); + + const { htmlToPdf } = await import("../utils/html-to-pdf"); + await expect(htmlToPdf("")).rejects.toThrow("Render timeout"); + expect(launchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/invoice-date-coercion.test.ts b/src/__tests__/invoice-date-coercion.test.ts new file mode 100644 index 0000000..44edf35 --- /dev/null +++ b/src/__tests__/invoice-date-coercion.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import invoicesRoutes from "../routes/admin/invoices"; +import { + CreateInvoiceSchema, + UpdateInvoiceSchema, +} from "../schemas/invoices.schema"; + +/** + * Pinning tests for audit finding M1: invoice date fields must go through the + * lenient ISO-date coercion. A round-tripped datetime string like + * "2026-03-02T00:00:00" (the toJSON override emits LOCAL time, no Z) parses + * as local Prague midnight = 23:00Z of the PREVIOUS day, and Prisma truncates + * @db.Date columns to the UTC date part — the invoice lands a day early and + * in the wrong month bucket. + */ + +let app: ReturnType; +let adminToken: string; +const createdInvoiceIds: number[] = []; + +beforeAll(async () => { + app = Fastify({ logger: false }); + await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +}); + +afterAll(async () => { + for (const id of createdInvoiceIds) + await prisma.invoices.deleteMany({ where: { id } }); + if (app) await app.close(); + await prisma.$disconnect(); +}); + +describe("invoice @db.Date coercion (audit M1)", () => { + it("stores the submitted calendar day when issue_date carries a time part", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/admin/invoices", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { + status: "draft", + issue_date: "2026-03-02T00:00:00", + items: [], + }, + }); + expect(res.statusCode).toBe(201); + const id = res.json().data.id as number; + createdInvoiceIds.push(id); + + const row = await prisma.invoices.findUnique({ where: { id } }); + // @db.Date reads back as UTC midnight of the stored calendar day. + expect(row?.issue_date?.toISOString().slice(0, 10)).toBe("2026-03-02"); + }); + + it("schema rejects a Czech-format date", () => { + const result = CreateInvoiceSchema.safeParse({ + issue_date: "15.03.2026", + }); + expect(result.success).toBe(false); + }); + + it("schema normalizes an empty string to null (edit forms clear with '')", () => { + const result = CreateInvoiceSchema.safeParse({ issue_date: "" }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.issue_date).toBeNull(); + }); + + it("update schema strips the time part from tax_date", () => { + const result = UpdateInvoiceSchema.safeParse({ + tax_date: "2026-03-02T00:00:00", + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.tax_date).toBe("2026-03-02"); + }); +}); diff --git a/src/__tests__/invoice-pdf-cnb-degrade.test.ts b/src/__tests__/invoice-pdf-cnb-degrade.test.ts new file mode 100644 index 0000000..7aaec8b --- /dev/null +++ b/src/__tests__/invoice-pdf-cnb-degrade.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import invoicesPdfRoutes from "../routes/admin/invoices-pdf"; +import { createInvoice } from "../services/invoices.service"; +import { getRate } from "../services/exchange-rates"; + +/** + * Pinning tests for audit finding M5: a foreign-currency invoice PDF must + * degrade gracefully when the CNB rate lookup fails — render WITHOUT the CZK + * VAT recap/conversion footnote instead of 500ing the whole preview AND the + * NAS archival. The rate only feeds the recap conversion; the document itself + * is renderable without it. + */ + +vi.mock("../services/exchange-rates", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, getRate: vi.fn() }; +}); + +let app: ReturnType; +let adminToken: string; +const createdInvoiceIds: number[] = []; + +beforeAll(async () => { + app = Fastify({ logger: false }); + await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +}); + +afterAll(async () => { + for (const id of createdInvoiceIds) + await prisma.invoices.deleteMany({ where: { id } }); + if (app) await app.close(); + await prisma.$disconnect(); +}); + +async function makeEurInvoice() { + // Draft → no number consumed; EUR → triggers the CNB rate lookup. + const invoice = await createInvoice({ + status: "draft", + currency: "EUR", + apply_vat: true, + vat_rate: 21, + items: [ + { + description: "CNB-DEGRADE-MARKER", + quantity: 1, + unit_price: 100, + vat_rate: 21, + }, + ], + }); + createdInvoiceIds.push(invoice.id); + return invoice; +} + +describe("invoice PDF vs unreachable CNB (audit M5)", () => { + it("renders the invoice without the CZK recap when the rate lookup throws", async () => { + vi.mocked(getRate).mockRejectedValue( + new Error("Nepodařilo se získat aktuální kurzy z ČNB"), + ); + const invoice = await makeEurInvoice(); + + const res = await app.inject({ + method: "GET", + url: `/api/admin/invoices-pdf/${invoice.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const html = res.body; + expect(html).toContain("CNB-DEGRADE-MARKER"); + // The CZK recap and the conversion footnote are omitted — their numbers + // cannot be computed without the rate. + expect(html).not.toContain("Rekapitulace DPH v Kč"); + expect(html).not.toContain("Přepočet kurzem ČNB"); + }); + + it("still renders the recap + conversion footnote when the rate is available", async () => { + vi.mocked(getRate).mockResolvedValue(25.0); + const invoice = await makeEurInvoice(); + + const res = await app.inject({ + method: "GET", + url: `/api/admin/invoices-pdf/${invoice.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const html = res.body; + expect(html).toContain("Rekapitulace DPH v Kč"); + expect(html).toContain("Přepočet kurzem ČNB"); + expect(html).toContain("25,000"); + }); +}); diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index c1847bf..831e6a6 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -539,6 +539,40 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => { expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat"); }); + it("persists item edits sent WITH a sent→confirmed transition (the H3 flush contract)", async () => { + // The IssuedOrderDetail transition flushes unsaved edits by sending the + // full payload + status in ONE PUT while the order is still editable + // (sent). The server must apply the items AND the transition together. + const order = await mkIssued({}); + await updateIssuedOrder(order.id, { status: "sent" }); + + const res = await app!.inject({ + method: "PUT", + url: `/api/admin/issued-orders/${order.id}`, + headers: { Authorization: `Bearer ${adminToken}` }, + payload: { + status: "confirmed", + items: [ + { + description: "Flushnutá položka", + quantity: 2, + unit_price: 50, + position: 0, + }, + ], + }, + }); + expect(res.statusCode).toBe(200); + const row = await prisma.issued_orders.findUnique({ + where: { id: order.id }, + include: { issued_order_items: true }, + }); + expect(row!.status).toBe("confirmed"); + expect(row!.issued_order_items.map((i) => i.description)).toContain( + "Flushnutá položka", + ); + }); + it("a status-only transition payload keeps working (confirmed -> completed)", async () => { const order = await mkIssued({}); await updateIssuedOrder(order.id, { status: "sent" }); diff --git a/src/__tests__/leave-requests-holidays.test.ts b/src/__tests__/leave-requests-holidays.test.ts new file mode 100644 index 0000000..27f5a2f --- /dev/null +++ b/src/__tests__/leave-requests-holidays.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import leaveRequestsRoutes from "../routes/admin/leave-requests"; + +/** + * Pinning tests for audit finding H6 + the year-spanning fast-follow: + * leave-request creation and approval must mirror attendance.service + * createLeave — skip Czech public holidays (a weekday holiday is already + * paid/free; charging it double-deducts) and book each day's hours against + * ITS OWN calendar year's balance (a Dec→Jan request used to charge + * everything to the start year). + * + * Fixture math (verified): 2098-05-01 (Thu) and 2098-05-08 (Thu) are both + * weekday holidays → the 05-01..05-08 range has 4 chargeable days (32h), + * not 6 (48h). 2098-12-28 (Sun)..2099-01-05 (Mon) has 3 chargeable days in + * 2098 (29/30/31 = 24h) and 2 in 2099 (Jan 2 + Jan 5 = 16h; Jan 1 is a + * holiday). + */ + +const N = "leave_holiday_"; + +let app: ReturnType; +let adminToken: string; +let userToken: string; +let userId: number; + +async function cleanup() { + const users = await prisma.users.findMany({ + where: { username: { startsWith: N } }, + select: { id: true }, + }); + const ids = users.map((u) => u.id); + if (ids.length > 0) { + await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } }); + await prisma.leave_requests.deleteMany({ + where: { user_id: { in: ids } }, + }); + await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } }); + await prisma.users.deleteMany({ where: { id: { in: ids } } }); + } +} + +beforeAll(async () => { + await cleanup(); + + app = Fastify({ logger: false }); + await app.register(leaveRequestsRoutes, { + prefix: "/api/admin/leave-requests", + }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); + + const role = await prisma.roles.findFirst(); + const user = await prisma.users.create({ + data: { + username: `${N}user`, + email: `${N}user@test.local`, + password_hash: + "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", + first_name: "Leave", + last_name: "Holiday", + is_active: true, + role_id: role!.id, + }, + }); + userId = user.id; + userToken = jwt.sign( + { sub: user.id, username: user.username, role: role!.name }, + config.jwt.secret, + { expiresIn: "15m" }, + ); + + for (const year of [2098, 2099]) { + await prisma.leave_balances.create({ + data: { + user_id: userId, + year, + vacation_total: 200, + vacation_used: 0, + sick_used: 0, + }, + }); + } +}); + +afterAll(async () => { + if (app) await app.close(); + await cleanup(); + await prisma.$disconnect(); +}); + +async function balance(year: number) { + return prisma.leave_balances.findFirst({ + where: { user_id: userId, year }, + }); +} + +describe("leave requests vs Czech holidays (audit H6)", () => { + it("creation excludes weekday public holidays from the charged total", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/admin/leave-requests", + headers: { + Authorization: `Bearer ${userToken}`, + "Content-Type": "application/json", + }, + payload: { + leave_type: "vacation", + date_from: "2098-05-01", + date_to: "2098-05-08", + }, + }); + expect(res.statusCode).toBe(201); + const row = await prisma.leave_requests.findFirst({ + where: { id: res.json().data.id }, + }); + expect(row?.total_days).toBe(4); + expect(Number(row?.total_hours)).toBe(32); + // Leave it cancelled so the approval test's range stays free. + await prisma.leave_requests.update({ + where: { id: row!.id }, + data: { status: "cancelled" }, + }); + }); + + it("approval charges only non-holiday business days and skips holiday attendance rows", async () => { + // Stored totals mimic a PRE-FIX request (weekend-only counting) — the + // approval must recompute, not trust them. + const req = await prisma.leave_requests.create({ + data: { + user_id: userId, + leave_type: "vacation", + date_from: new Date(Date.UTC(2098, 4, 1)), + date_to: new Date(Date.UTC(2098, 4, 8)), + total_hours: 48, + total_days: 6, + status: "pending", + }, + }); + + const res = await app.inject({ + method: "PUT", + url: `/api/admin/leave-requests/${req.id}`, + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { status: "approved" }, + }); + expect(res.statusCode).toBe(200); + + expect(Number((await balance(2098))?.vacation_used)).toBe(32); + + const rows = await prisma.attendance.findMany({ + where: { user_id: userId, leave_type: "vacation" }, + }); + const days = rows.map((r) => r.shift_date.toISOString().slice(0, 10)); + expect(rows).toHaveLength(4); + expect(days).not.toContain("2098-05-01"); + expect(days).not.toContain("2098-05-08"); + }); + + it("a year-spanning approval books each day against its own year's balance", async () => { + const req = await prisma.leave_requests.create({ + data: { + user_id: userId, + leave_type: "sick", + date_from: new Date(Date.UTC(2098, 11, 28)), + date_to: new Date(Date.UTC(2099, 0, 5)), + total_hours: 48, + total_days: 6, + status: "pending", + }, + }); + + const res = await app.inject({ + method: "PUT", + url: `/api/admin/leave-requests/${req.id}`, + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { status: "approved" }, + }); + expect(res.statusCode).toBe(200); + + // 29/30/31 Dec 2098 = 24h; Jan 2 + Jan 5 2099 = 16h (Jan 1 is a holiday). + expect(Number((await balance(2098))?.sick_used)).toBe(24); + expect(Number((await balance(2099))?.sick_used)).toBe(16); + }); +}); diff --git a/src/__tests__/offer-number-release.test.ts b/src/__tests__/offer-number-release.test.ts new file mode 100644 index 0000000..4c91d87 --- /dev/null +++ b/src/__tests__/offer-number-release.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import prisma from "../config/database"; +import { deleteOffer } from "../services/offers.service"; +import { applyPattern } from "../services/numbering.service"; + +/** + * Pinning test for audit finding L2: an offer created in year Y but + * finalized (numbered) in year Y+1 must release the Y+1 sequence on delete. + * deleteOffer used to derive the release year from created_at, so the + * reconstructed highest number ("2025/NA/...") never matched the actual + * "2026/NA/..." and the counter kept a permanent gap. + */ + +const CURRENT_YEAR = new Date().getFullYear(); + +let offerId: number; +let originalLastNumber: number | null = null; // null = row absent before test +let offerNumber: string; + +async function getSeq(): Promise { + const rows = await prisma.$queryRaw>` + SELECT last_number FROM number_sequences + WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR} + `; + return rows.length > 0 ? Number(rows[0].last_number) : null; +} + +beforeAll(async () => { + const settings = await prisma.company_settings.findFirst(); + const pattern = settings?.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}"; + const prefix = settings?.quotation_prefix || "NA"; + + originalLastNumber = await getSeq(); + const base = originalLastNumber ?? 0; + const seq = base + 1; + offerNumber = applyPattern(pattern, { + year: CURRENT_YEAR, + prefix, + code: "", + seq, + }); + + // Consume the number in the sequence (what finalize would have done). + if (originalLastNumber === null) { + await prisma.$executeRaw` + INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`) + VALUES ('offer', ${CURRENT_YEAR}, ${seq}) + `; + } else { + await prisma.$executeRaw` + UPDATE number_sequences SET \`last_number\` = ${seq} + WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR} + `; + } + + // The offer itself was CREATED last year (draft), finalized this year — + // its number carries the finalize year, created_at the draft year. + const offer = await prisma.quotations.create({ + data: { + quotation_number: offerNumber, + status: "active", + currency: "CZK", + language: "cs", + created_at: new Date(CURRENT_YEAR - 1, 11, 30, 12, 0, 0), + }, + }); + offerId = offer.id; +}); + +afterAll(async () => { + await prisma.quotations + .deleteMany({ where: { id: offerId } }) + .catch(() => {}); + // Restore the sequence to its pre-test value regardless of outcome. + if (originalLastNumber === null) { + await prisma.$executeRaw` + DELETE FROM number_sequences + WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR} + `; + } else { + await prisma.$executeRaw` + UPDATE number_sequences SET \`last_number\` = ${originalLastNumber} + WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR} + `; + } + await prisma.$disconnect(); +}); + +describe("offer sequence release across years (audit L2)", () => { + it("releases the finalize-year sequence when created_at is in a previous year", async () => { + const before = await getSeq(); + expect(before).toBe((originalLastNumber ?? 0) + 1); + + const result = await deleteOffer(offerId); + expect(result && "error" in result ? result.error : null).toBeNull(); + + // The highest number was just deleted → the counter must step back. + expect(await getSeq()).toBe(originalLastNumber ?? 0); + }); +}); diff --git a/src/__tests__/pagination-tiebreak.test.ts b/src/__tests__/pagination-tiebreak.test.ts new file mode 100644 index 0000000..283b4f8 --- /dev/null +++ b/src/__tests__/pagination-tiebreak.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import customersRoutes from "../routes/admin/customers"; +import receivedInvoicesRoutes from "../routes/admin/received-invoices"; + +/** + * Pinning tests for audit finding L3: offset pagination over a non-unique + * sort column needs an `{ id }` tiebreak, or rows sharing the sort value can + * reorder between page queries — duplicating one row and skipping another. + * (Documented determinism convention; siblings issued-orders/trips already + * use compound orderings.) + * + * NOTE: without the tiebreak the misbehavior is probabilistic (MySQL may + * return a stable order by luck), so the paging assertion is primarily a + * REGRESSION GUARD pinning the exactly-once contract. + */ + +const N = "tiebreak_test_"; + +let app: ReturnType; +let adminToken: string; +let customerIds: number[] = []; +let invoiceIds: number[] = []; + +async function cleanup() { + await prisma.customers.deleteMany({ where: { name: { startsWith: N } } }); + await prisma.received_invoices.deleteMany({ + where: { supplier_name: { startsWith: N } }, + }); +} + +beforeAll(async () => { + await cleanup(); + + app = Fastify({ logger: false }); + await app.register(customersRoutes, { prefix: "/api/admin/customers" }); + await app.register(receivedInvoicesRoutes, { + prefix: "/api/admin/received-invoices", + }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); + + // 5 customers sharing one city (the sort key) so every page boundary + // falls inside a duplicate-key run. + customerIds = []; + for (let i = 0; i < 5; i++) { + const c = await prisma.customers.create({ + data: { name: `${N}c${i}`, city: "Tiebreakov" }, + }); + customerIds.push(c.id); + } + + // 5 received invoices sharing supplier_name in one far-future month. + invoiceIds = []; + for (let i = 0; i < 5; i++) { + const inv = await prisma.received_invoices.create({ + data: { + supplier_name: `${N}supplier`, + month: 7, + year: 2098, + amount: 100 + i, + currency: "CZK", + vat_rate: 21, + vat_amount: 17.36, + status: "unpaid", + }, + }); + invoiceIds.push(inv.id); + } +}); + +afterAll(async () => { + if (app) await app.close(); + await cleanup(); + await prisma.$disconnect(); +}); + +async function collectPages(urlBase: string, pages: number) { + const seen: number[] = []; + for (let page = 1; page <= pages; page++) { + const res = await app.inject({ + method: "GET", + url: `${urlBase}&page=${page}&limit=2`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + for (const row of res.json().data as Array<{ id: number }>) { + seen.push(row.id); + } + } + return seen; +} + +describe("offset pagination exactly-once (audit L3)", () => { + it("customers sorted by a duplicate city appear exactly once across pages", async () => { + const seen = ( + await collectPages("/api/admin/customers?sort=city&search=" + N, 3) + ).filter((id) => customerIds.includes(id)); + + expect([...new Set(seen)].sort()).toEqual([...customerIds].sort()); + expect(seen.length).toBe(customerIds.length); + }); + + it("received invoices sorted by a duplicate supplier appear exactly once across pages", async () => { + const seen = ( + await collectPages( + "/api/admin/received-invoices?sort=supplier_name&month=7&year=2098", + 3, + ) + ).filter((id) => invoiceIds.includes(id)); + + expect([...new Set(seen)].sort()).toEqual([...invoiceIds].sort()); + expect(seen.length).toBe(invoiceIds.length); + }); +}); diff --git a/src/__tests__/plan-update-cap.test.ts b/src/__tests__/plan-update-cap.test.ts new file mode 100644 index 0000000..f1a4366 --- /dev/null +++ b/src/__tests__/plan-update-cap.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import prisma from "../config/database"; +import { updateEntry } from "../services/plan.service"; + +/** + * Pinning tests for audit finding M9: updateEntry must re-check the per-cell + * cap (MAX_RECORDS_PER_CELL = 3) when the date range changes — expanding an + * entry onto a day that already holds 3 active entries used to succeed, + * producing a 4th that the grid (capped at 3) silently hides. + * + * The re-check must EXCLUDE the updated entry itself, so editing an at-cap + * entry without moving it stays allowed. + */ + +const NOTE = "plan_updcap_test"; + +let userId: number; +let cappedEntryIds: number[] = []; +let fourthId: number; + +const D = (day: number) => new Date(Date.UTC(2099, 6, day)); // July 2099, UTC + +async function cleanup() { + await prisma.work_plan_entries.deleteMany({ where: { note: NOTE } }); +} + +beforeAll(async () => { + await cleanup(); + const user = await prisma.users.findFirst({ select: { id: true } }); + if (!user) throw new Error("Test setup: no users — seed the database"); + userId = user.id; + + cappedEntryIds = []; + for (let i = 0; i < 3; i++) { + const entry = await prisma.work_plan_entries.create({ + data: { + user_id: userId, + date_from: D(1), + date_to: D(1), + category: "work", + note: NOTE, + created_by: userId, + }, + }); + cappedEntryIds.push(entry.id); + } + const fourth = await prisma.work_plan_entries.create({ + data: { + user_id: userId, + date_from: D(2), + date_to: D(2), + category: "work", + note: NOTE, + created_by: userId, + }, + }); + fourthId = fourth.id; +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("updateEntry per-cell cap (audit M9)", () => { + it("rejects expanding an entry onto a day already at the cap", async () => { + const result = await updateEntry( + fourthId, + { date_from: "2099-07-01", date_to: "2099-07-01" }, + userId, + false, + ); + + expect("error" in result).toBe(true); + if ("error" in result) expect(result.status).toBe(400); + + // The entry must not have moved. + const fresh = await prisma.work_plan_entries.findUnique({ + where: { id: fourthId }, + }); + expect(fresh?.date_from.toISOString().slice(0, 10)).toBe("2099-07-02"); + }); + + it("still allows editing an at-cap entry without moving it (self-exclusion)", async () => { + const result = await updateEntry( + cappedEntryIds[0], + { date_from: "2099-07-01", date_to: "2099-07-01", note: NOTE }, + userId, + false, + ); + expect("error" in result).toBe(false); + }); + + it("still allows a note-only edit and a move to a free day", async () => { + const noteOnly = await updateEntry(fourthId, { note: NOTE }, userId, false); + expect("error" in noteOnly).toBe(false); + + const move = await updateEntry( + fourthId, + { date_from: "2099-07-03", date_to: "2099-07-03" }, + userId, + false, + ); + expect("error" in move).toBe(false); + }); +}); diff --git a/src/__tests__/projects-update-fk.test.ts b/src/__tests__/projects-update-fk.test.ts new file mode 100644 index 0000000..34469d1 --- /dev/null +++ b/src/__tests__/projects-update-fk.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import prisma from "../config/database"; +import { updateProject } from "../services/projects.service"; + +/** + * Pinning tests for audit finding M6: updateProject must pre-validate FK ids + * (customer_id, responsible_user_id, quotation_id, order_id) and return clean + * Czech 400s like createProject does — a dangling id otherwise raises P2003 + * at Prisma and surfaces as a generic 500. + */ + +const N = "prj_fk_"; + +let projectId: number; + +async function cleanup() { + await prisma.projects.deleteMany({ where: { name: { startsWith: N } } }); +} + +beforeAll(async () => { + await cleanup(); + const project = await prisma.projects.create({ + data: { name: `${N}project`, status: "active" }, + }); + projectId = project.id; +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +function isError(r: unknown): r is { error: string; status: number } { + return typeof r === "object" && r !== null && "error" in r; +} + +describe("updateProject FK existence checks (audit M6)", () => { + it.each([ + ["customer_id", "Zákazník nenalezen"], + ["responsible_user_id", "Zodpovědná osoba nenalezena"], + ["quotation_id", "Nabídka nenalezena"], + ["order_id", "Zakázka nenalezena"], + ])("returns a Czech 400 for a dangling %s", async (field, message) => { + const result = await updateProject(projectId, { [field]: 99999999 }); + + expect(isError(result)).toBe(true); + if (isError(result)) { + expect(result.status).toBe(400); + expect(result.error).toBe(message); + } + + // Nothing was written. + const fresh = await prisma.projects.findUnique({ + where: { id: projectId }, + }); + expect(fresh?.[field as "customer_id"]).toBeNull(); + }); + + it("still allows clearing an FK with null", async () => { + const result = await updateProject(projectId, { customer_id: null }); + expect(isError(result)).toBe(false); + }); + + it("still allows setting a valid FK", async () => { + const user = await prisma.users.findFirst({ select: { id: true } }); + expect(user).not.toBeNull(); + const result = await updateProject(projectId, { + responsible_user_id: user!.id, + }); + expect(isError(result)).toBe(false); + const fresh = await prisma.projects.findUnique({ + where: { id: projectId }, + }); + expect(fresh?.responsible_user_id).toBe(user!.id); + }); +}); diff --git a/src/__tests__/received-invoice-dates.test.ts b/src/__tests__/received-invoice-dates.test.ts new file mode 100644 index 0000000..c673bc2 --- /dev/null +++ b/src/__tests__/received-invoice-dates.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { z } from "zod"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import prisma from "../config/database"; +import { config } from "../config/env"; +import receivedInvoicesRoutes from "../routes/admin/received-invoices"; +import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema"; + +/** + * Pinning tests for audit finding H5: Czech-format dates in the received- + * invoice upload metadata must be rejected with a clean Czech 400 BEFORE any + * NAS side effect — "15.03.2026" used to parse to Invalid Date (NaN + * month/year written after the NAS save → 500 + orphaned file) and + * "12.6.2026" silently filed under December (US month-first parsing). + */ + +let app: ReturnType; +let adminToken: string; + +beforeAll(async () => { + app = Fastify({ logger: false }); + await app.register(receivedInvoicesRoutes, { + prefix: "/api/admin/received-invoices", + }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +}); + +afterAll(async () => { + if (app) await app.close(); + await prisma.$disconnect(); +}); + +function multipartUpload(meta: Record) { + const boundary = "----vitestboundary"; + const payload = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="invoices"', + "", + JSON.stringify([meta]), + `--${boundary}`, + 'Content-Disposition: form-data; name="files"; filename="test.pdf"', + "Content-Type: application/pdf", + "", + "%PDF-1.4 test", + `--${boundary}--`, + "", + ].join("\r\n"); + return app.inject({ + method: "POST", + url: "/api/admin/received-invoices", + headers: { + Authorization: `Bearer ${adminToken}`, + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + payload, + }); +} + +describe("received-invoice date handling (audit H5)", () => { + const partialSchema = z.array(CreateReceivedInvoiceSchema.partial()); + + it("schema rejects Czech-format dates", () => { + expect( + partialSchema.safeParse([{ issue_date: "15.03.2026" }]).success, + ).toBe(false); + expect(partialSchema.safeParse([{ issue_date: "12.6.2026" }]).success).toBe( + false, + ); + }); + + it("schema strips a trailing time part and normalizes '' to null", () => { + const withTime = partialSchema.safeParse([ + { issue_date: "2098-05-03T00:00:00" }, + ]); + expect(withTime.success).toBe(true); + if (withTime.success) + expect(withTime.data[0].issue_date).toBe("2098-05-03"); + + const empty = partialSchema.safeParse([{ issue_date: "" }]); + expect(empty.success).toBe(true); + if (empty.success) expect(empty.data[0].issue_date).toBeNull(); + }); + + it("upload with a Czech-format issue_date returns 400 before any NAS write", async () => { + const res = await multipartUpload({ + supplier_name: "Test s.r.o.", + amount: 121, + vat_rate: 21, + issue_date: "15.03.2026", + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("Datum"); + }); + + it("upload with an impossible regex-passing date returns 400, not NaN month", async () => { + const res = await multipartUpload({ + supplier_name: "Test s.r.o.", + amount: 121, + vat_rate: 21, + issue_date: "2098-99-99", + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("datum"); + }); +}); diff --git a/src/__tests__/schema-caps.test.ts b/src/__tests__/schema-caps.test.ts new file mode 100644 index 0000000..a9731ad --- /dev/null +++ b/src/__tests__/schema-caps.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect } from "vitest"; +import type { z } from "zod"; +import { + CreateOrderSchema, + UpdateOrderSchema, + CreateOrderFromQuotationSchema, +} from "../schemas/orders.schema"; +import { + CreateInvoiceSchema, + UpdateInvoiceSchema, +} from "../schemas/invoices.schema"; +import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema"; +import { TotpEnableSchema } from "../schemas/auth.schema"; +import { + CreateReceivedInvoiceSchema, + UpdateReceivedInvoiceSchema, +} from "../schemas/received-invoices.schema"; +import { + CreateSupplierSchema, + UpdateSupplierSchema, +} from "../schemas/warehouse.schema"; +import { + CreateCustomerSchema, + UpdateCustomerSchema, +} from "../schemas/customers.schema"; + +/** + * Pinning tests for the Zod-cap-vs-DB-column class (audit H2, M2, M4, M11, + * L1, L8d, L8e + adjacent invoice item/bank caps): every form-facing string + * cap must fit its DB column width, or an over-width value passes Zod and + * dies at Prisma as a 500 (P2000) instead of a clean Czech 400. + * + * Each case asserts BOTH directions: width+1 chars must FAIL the schema, and + * exactly width chars must PASS (guards against over-tightening — stored + * values can never exceed the column width, so re-submitted edit forms stay + * valid). + */ + +interface CapCase { + name: string; + schema: z.ZodTypeAny; + /** DB column width from prisma/schema.prisma */ + width: number; + build: (value: string) => unknown; +} + +const tripBase = { + vehicle_id: 1, + trip_date: "2098-03-02", + start_km: 0, + end_km: 1, + route_from: "A", + route_to: "B", +}; +const receivedBase = { supplier_name: "X", month: 6, year: 2026 }; + +const CASES: CapCase[] = [ + // ── orders.schema.ts vs order_items / orders ── + { + name: "CreateOrderSchema items.description", + schema: CreateOrderSchema, + width: 500, + build: (v) => ({ items: [{ description: v }] }), + }, + { + name: "CreateOrderSchema items.unit", + schema: CreateOrderSchema, + width: 20, + build: (v) => ({ items: [{ unit: v }] }), + }, + { + name: "CreateOrderSchema order_number", + schema: CreateOrderSchema, + width: 50, + build: (v) => ({ order_number: v }), + }, + { + name: "CreateOrderSchema customer_order_number", + schema: CreateOrderSchema, + width: 100, + build: (v) => ({ customer_order_number: v }), + }, + { + name: "CreateOrderSchema currency", + schema: CreateOrderSchema, + width: 10, + build: (v) => ({ currency: v }), + }, + { + name: "CreateOrderSchema language", + schema: CreateOrderSchema, + width: 5, + build: (v) => ({ language: v }), + }, + { + name: "UpdateOrderSchema customer_order_number", + schema: UpdateOrderSchema, + width: 100, + build: (v) => ({ customer_order_number: v }), + }, + { + name: "UpdateOrderSchema currency", + schema: UpdateOrderSchema, + width: 10, + build: (v) => ({ currency: v }), + }, + { + name: "UpdateOrderSchema language", + schema: UpdateOrderSchema, + width: 5, + build: (v) => ({ language: v }), + }, + { + name: "CreateOrderFromQuotationSchema customerOrderNumber", + schema: CreateOrderFromQuotationSchema, + width: 100, + build: (v) => ({ quotationId: 1, customerOrderNumber: v }), + }, + // ── invoices.schema.ts vs invoice_items / invoices ── + { + name: "CreateInvoiceSchema items.description", + schema: CreateInvoiceSchema, + width: 500, + build: (v) => ({ items: [{ description: v }] }), + }, + { + name: "CreateInvoiceSchema items.unit", + schema: CreateInvoiceSchema, + width: 20, + build: (v) => ({ items: [{ unit: v }] }), + }, + { + name: "CreateInvoiceSchema invoice_number", + schema: CreateInvoiceSchema, + width: 50, + build: (v) => ({ invoice_number: v }), + }, + { + name: "CreateInvoiceSchema currency", + schema: CreateInvoiceSchema, + width: 10, + build: (v) => ({ currency: v }), + }, + { + name: "CreateInvoiceSchema language", + schema: CreateInvoiceSchema, + width: 5, + build: (v) => ({ language: v }), + }, + { + name: "CreateInvoiceSchema payment_method", + schema: CreateInvoiceSchema, + width: 50, + build: (v) => ({ payment_method: v }), + }, + { + name: "CreateInvoiceSchema constant_symbol", + schema: CreateInvoiceSchema, + width: 20, + build: (v) => ({ constant_symbol: v }), + }, + { + name: "CreateInvoiceSchema bank_swift", + schema: CreateInvoiceSchema, + width: 20, + build: (v) => ({ bank_swift: v }), + }, + { + name: "CreateInvoiceSchema bank_iban", + schema: CreateInvoiceSchema, + width: 50, + build: (v) => ({ bank_iban: v }), + }, + { + name: "CreateInvoiceSchema bank_account", + schema: CreateInvoiceSchema, + width: 50, + build: (v) => ({ bank_account: v }), + }, + { + name: "CreateInvoiceSchema billing_text", + schema: CreateInvoiceSchema, + width: 500, + build: (v) => ({ billing_text: v }), + }, + { + name: "UpdateInvoiceSchema currency", + schema: UpdateInvoiceSchema, + width: 10, + build: (v) => ({ currency: v }), + }, + { + name: "UpdateInvoiceSchema language", + schema: UpdateInvoiceSchema, + width: 5, + build: (v) => ({ language: v }), + }, + { + name: "UpdateInvoiceSchema payment_method", + schema: UpdateInvoiceSchema, + width: 50, + build: (v) => ({ payment_method: v }), + }, + { + name: "UpdateInvoiceSchema constant_symbol", + schema: UpdateInvoiceSchema, + width: 20, + build: (v) => ({ constant_symbol: v }), + }, + { + name: "UpdateInvoiceSchema bank_swift", + schema: UpdateInvoiceSchema, + width: 20, + build: (v) => ({ bank_swift: v }), + }, + { + name: "UpdateInvoiceSchema bank_iban", + schema: UpdateInvoiceSchema, + width: 50, + build: (v) => ({ bank_iban: v }), + }, + { + name: "UpdateInvoiceSchema bank_account", + schema: UpdateInvoiceSchema, + width: 50, + build: (v) => ({ bank_account: v }), + }, + { + name: "UpdateInvoiceSchema billing_text", + schema: UpdateInvoiceSchema, + width: 500, + build: (v) => ({ billing_text: v }), + }, + // ── trips.schema.ts vs trips ── + { + name: "CreateTripSchema route_from", + schema: CreateTripSchema, + width: 100, + build: (v) => ({ ...tripBase, route_from: v }), + }, + { + name: "CreateTripSchema route_to", + schema: CreateTripSchema, + width: 100, + build: (v) => ({ ...tripBase, route_to: v }), + }, + { + name: "UpdateTripSchema route_from", + schema: UpdateTripSchema, + width: 100, + build: (v) => ({ route_from: v }), + }, + { + name: "UpdateTripSchema route_to", + schema: UpdateTripSchema, + width: 100, + build: (v) => ({ route_to: v }), + }, + // ── auth.schema.ts vs users.totp_secret ── + // The stored value is AES-256-GCM ciphertext (base64(IV+ct+tag)) of the + // plaintext secret in a VarChar(255) column: a plaintext over ~164 chars + // overflows after encryption. Real TOTP secrets are 16-32 chars; cap 64. + { + name: "TotpEnableSchema secret", + schema: TotpEnableSchema, + width: 64, + build: (v) => ({ secret: v, code: "123456" }), + }, + // ── received-invoices.schema.ts vs received_invoices ── + { + name: "CreateReceivedInvoiceSchema description", + schema: CreateReceivedInvoiceSchema, + width: 500, + build: (v) => ({ ...receivedBase, description: v }), + }, + { + name: "CreateReceivedInvoiceSchema invoice_number", + schema: CreateReceivedInvoiceSchema, + width: 100, + build: (v) => ({ ...receivedBase, invoice_number: v }), + }, + { + name: "CreateReceivedInvoiceSchema currency", + schema: CreateReceivedInvoiceSchema, + width: 3, + build: (v) => ({ ...receivedBase, currency: v }), + }, + { + name: "UpdateReceivedInvoiceSchema description", + schema: UpdateReceivedInvoiceSchema, + width: 500, + build: (v) => ({ description: v }), + }, + { + name: "UpdateReceivedInvoiceSchema invoice_number", + schema: UpdateReceivedInvoiceSchema, + width: 100, + build: (v) => ({ invoice_number: v }), + }, + { + name: "UpdateReceivedInvoiceSchema currency", + schema: UpdateReceivedInvoiceSchema, + width: 3, + build: (v) => ({ currency: v }), + }, + // ── warehouse.schema.ts (suppliers) vs sklad_suppliers ── + { + name: "CreateSupplierSchema ico", + schema: CreateSupplierSchema, + width: 20, + build: (v) => ({ name: "X", ico: v }), + }, + { + name: "CreateSupplierSchema dic", + schema: CreateSupplierSchema, + width: 20, + build: (v) => ({ name: "X", dic: v }), + }, + { + name: "UpdateSupplierSchema ico", + schema: UpdateSupplierSchema, + width: 20, + build: (v) => ({ ico: v }), + }, + { + name: "UpdateSupplierSchema dic", + schema: UpdateSupplierSchema, + width: 20, + build: (v) => ({ dic: v }), + }, + // ── customers.schema.ts vs customers ── + { + name: "CreateCustomerSchema company_id", + schema: CreateCustomerSchema, + width: 50, + build: (v) => ({ name: "X", company_id: v }), + }, + { + name: "CreateCustomerSchema vat_id", + schema: CreateCustomerSchema, + width: 50, + build: (v) => ({ name: "X", vat_id: v }), + }, + { + name: "CreateCustomerSchema postal_code", + schema: CreateCustomerSchema, + width: 20, + build: (v) => ({ name: "X", postal_code: v }), + }, + { + name: "CreateCustomerSchema country", + schema: CreateCustomerSchema, + width: 100, + build: (v) => ({ name: "X", country: v }), + }, + { + name: "UpdateCustomerSchema company_id", + schema: UpdateCustomerSchema, + width: 50, + build: (v) => ({ company_id: v }), + }, + { + name: "UpdateCustomerSchema vat_id", + schema: UpdateCustomerSchema, + width: 50, + build: (v) => ({ vat_id: v }), + }, + { + name: "UpdateCustomerSchema postal_code", + schema: UpdateCustomerSchema, + width: 20, + build: (v) => ({ postal_code: v }), + }, + { + name: "UpdateCustomerSchema country", + schema: UpdateCustomerSchema, + width: 100, + build: (v) => ({ country: v }), + }, +]; + +describe("Zod caps fit DB column widths (audit H2/M2/M4/M11/L1/L8d/L8e + adjacent)", () => { + for (const c of CASES) { + it(`${c.name} rejects ${c.width + 1} chars`, () => { + const result = c.schema.safeParse(c.build("x".repeat(c.width + 1))); + expect(result.success).toBe(false); + }); + + it(`${c.name} accepts ${c.width} chars`, () => { + const result = c.schema.safeParse(c.build("x".repeat(c.width))); + expect(result.success).toBe(true); + }); + } +}); diff --git a/src/__tests__/schema-nan.test.ts b/src/__tests__/schema-nan.test.ts index 3299017..4ccc15e 100644 --- a/src/__tests__/schema-nan.test.ts +++ b/src/__tests__/schema-nan.test.ts @@ -85,11 +85,13 @@ describe("Zod form coercion + NaN rejection", () => { describe("CreateTripSchema", () => { it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => { + // km fields are Int columns — integer strings coerce, decimals are + // rejected (see trips-vehicles-km-int.test.ts for the rejection pins). const result = CreateTripSchema.safeParse({ vehicle_id: "5", trip_date: "2026-01-15", start_km: "100", - end_km: "150.5", + end_km: "150", route_from: "Praha", route_to: "Brno", }); @@ -97,7 +99,7 @@ describe("Zod form coercion + NaN rejection", () => { if (result.success) { expect(result.data.vehicle_id).toBe(5); expect(result.data.start_km).toBe(100); - expect(result.data.end_km).toBe(150.5); + expect(result.data.end_km).toBe(150); expect(typeof result.data.start_km).toBe("number"); } }); diff --git a/src/__tests__/session-terminate-refresh.test.ts b/src/__tests__/session-terminate-refresh.test.ts new file mode 100644 index 0000000..dd910a7 --- /dev/null +++ b/src/__tests__/session-terminate-refresh.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { FastifyRequest } from "fastify"; +import prisma from "../config/database"; +import { refreshAccessToken, hashToken } from "../services/auth"; + +/** + * Pinning tests for audit finding H1: a session terminated via the sessions + * routes (replaced_at set, NO replaced_by_hash) presented on a later refresh + * is NOT token theft — it must be rejected with a plain 401 WITHOUT revoking + * the user's other sessions. Real theft (replay of a ROTATED token, i.e. + * replaced_by_hash set) must keep revoking the whole family. + */ + +const N = "sess_term_"; +const stamp = Date.now().toString(36); + +const fakeReq = { + log: { warn: () => {} }, + ip: "127.0.0.1", + headers: {}, +} as unknown as FastifyRequest; + +let userId: number; + +const rawA = `${N}rawA_${stamp}`; +const rawB = `${N}rawB_${stamp}`; +const rawC = `${N}rawC_${stamp}`; +const rawD = `${N}rawD_${stamp}`; + +let tokenAId: number; +let tokenDId: number; + +async function cleanup() { + const users = await prisma.users.findMany({ + where: { username: { startsWith: N } }, + select: { id: true }, + }); + const ids = users.map((u) => u.id); + if (ids.length > 0) { + await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } }); + await prisma.users.deleteMany({ where: { id: { in: ids } } }); + } +} + +beforeAll(async () => { + await cleanup(); + const role = await prisma.roles.findFirst(); + if (!role) throw new Error("Test setup: no roles found — seed the database"); + const user = await prisma.users.create({ + data: { + username: `${N}user_${stamp}`, + email: `${N}${stamp}@test.local`, + password_hash: + "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", + first_name: "Session", + last_name: "Terminate", + is_active: true, + role_id: role.id, + }, + }); + userId = user.id; + + const future = new Date(Date.now() + 7 * 24 * 3600 * 1000); + const tokenA = await prisma.refresh_tokens.create({ + data: { + user_id: userId, + token_hash: hashToken(rawA), + expires_at: future, + remember_me: false, + }, + }); + tokenAId = tokenA.id; + // Token B: terminated the way DELETE /sessions/:id does it — replaced_at + // ONLY, no replaced_by_hash, row kept. + await prisma.refresh_tokens.create({ + data: { + user_id: userId, + token_hash: hashToken(rawB), + expires_at: future, + remember_me: false, + replaced_at: new Date(), + }, + }); + // Token C: legitimately ROTATED (both replaced_at and replaced_by_hash set) + // — replaying it is the theft signature. + await prisma.refresh_tokens.create({ + data: { + user_id: userId, + token_hash: hashToken(rawC), + expires_at: future, + remember_me: false, + replaced_at: new Date(), + replaced_by_hash: hashToken(`${N}descendant_${stamp}`), + }, + }); + const tokenD = await prisma.refresh_tokens.create({ + data: { + user_id: userId, + token_hash: hashToken(rawD), + expires_at: future, + remember_me: false, + }, + }); + tokenDId = tokenD.id; +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("refreshAccessToken vs terminated sessions (audit H1)", () => { + it("rejects a manually terminated token with 401 WITHOUT revoking other sessions", async () => { + const result = await refreshAccessToken(rawB, fakeReq); + + expect(result.type).toBe("error"); + if (result.type === "error") expect(result.status).toBe(401); + + // The user's OTHER session must survive — termination is not theft. + const tokenA = await prisma.refresh_tokens.findUnique({ + where: { id: tokenAId }, + }); + expect(tokenA).not.toBeNull(); + }); + + it("still revokes the whole family on replay of a ROTATED token (theft)", async () => { + const result = await refreshAccessToken(rawC, fakeReq); + + expect(result.type).toBe("error"); + + // Breach containment must keep working: every session of the user is + // gone, including the otherwise-valid token D. + const tokenD = await prisma.refresh_tokens.findUnique({ + where: { id: tokenDId }, + }); + expect(tokenD).toBeNull(); + }); +}); diff --git a/src/__tests__/trips-vehicles-km-int.test.ts b/src/__tests__/trips-vehicles-km-int.test.ts new file mode 100644 index 0000000..2f75da1 --- /dev/null +++ b/src/__tests__/trips-vehicles-km-int.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema"; +import { + CreateVehicleSchema, + UpdateVehicleSchema, +} from "../schemas/vehicles.schema"; + +/** + * Pinning tests for audit finding L7: odometer fields are Int columns — + * a decimal reading must 400 at Zod instead of 500ing at Prisma (or + * silently truncating, corrupting the derived distance and + * vehicles.actual_km). + */ + +const tripBase = { + vehicle_id: 1, + trip_date: "2098-01-15", + start_km: 100, + end_km: 200, + route_from: "A", + route_to: "B", +}; + +describe("km fields are integers (audit L7)", () => { + it.each([["start_km"], ["end_km"]])( + "CreateTripSchema rejects a decimal %s", + (field) => { + expect( + CreateTripSchema.safeParse({ ...tripBase, [field]: 100.5 }).success, + ).toBe(false); + }, + ); + + it("CreateTripSchema still accepts integer readings", () => { + expect(CreateTripSchema.safeParse(tripBase).success).toBe(true); + }); + + it("UpdateTripSchema rejects a decimal start_km", () => { + expect(UpdateTripSchema.safeParse({ start_km: 100.5 }).success).toBe(false); + }); + + it.each([["initial_km"], ["actual_km"]])( + "CreateVehicleSchema rejects a decimal %s", + (field) => { + expect( + CreateVehicleSchema.safeParse({ + spz: "1AB 1234", + name: "Test", + [field]: 12345.5, + }).success, + ).toBe(false); + }, + ); + + it("UpdateVehicleSchema rejects a decimal actual_km, accepts an integer", () => { + expect(UpdateVehicleSchema.safeParse({ actual_km: 12345.5 }).success).toBe( + false, + ); + expect(UpdateVehicleSchema.safeParse({ actual_km: 12345 }).success).toBe( + true, + ); + }); +}); diff --git a/src/__tests__/users-create-role-guard.test.ts b/src/__tests__/users-create-role-guard.test.ts new file mode 100644 index 0000000..a232c78 --- /dev/null +++ b/src/__tests__/users-create-role-guard.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, 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 { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import usersRoutes from "../routes/admin/users"; + +/** + * Pinning tests for audit finding M10: POST /users must strip role_id for + * non-admin callers (mirroring the PUT privilege-escalation guard) — a bare + * users.create holder must not be able to mint an account on a privileged + * role the caller itself lacks. + */ + +const N = "usr_roleguard_"; +const stamp = Date.now().toString(36); + +let app: ReturnType; +let adminToken: string; +let creatorToken: string; +let creatorRoleId: number; +let privilegedRoleId: number; + +function token(user: { id: number; username: string; roleName: string }) { + return jwt.sign( + { sub: user.id, username: user.username, role: user.roleName }, + config.jwt.secret, + { expiresIn: "15m" }, + ); +} + +async function cleanup() { + await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); + const roles = await prisma.roles.findMany({ + where: { name: { startsWith: N } }, + select: { id: true }, + }); + const roleIds = roles.map((r) => r.id); + if (roleIds.length > 0) { + await prisma.role_permissions.deleteMany({ + where: { role_id: { in: roleIds } }, + }); + await prisma.roles.deleteMany({ where: { id: { in: roleIds } } }); + } +} + +beforeAll(async () => { + await cleanup(); + + app = Fastify({ logger: false }); + await app.register(cookie); + await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + app.addHook("onRequest", securityHeaders); + await app.register(usersRoutes, { prefix: "/api/admin/users" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + include: { roles: true }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = token({ + id: admin.id, + username: admin.username, + roleName: "admin", + }); + + // Caller role: ONLY users.create. + const creatorRole = await prisma.roles.create({ + data: { name: `${N}creator_${stamp}`, display_name: "Creator Only" }, + }); + creatorRoleId = creatorRole.id; + const perm = await prisma.permissions.findFirst({ + where: { name: "users.create" }, + }); + if (!perm) throw new Error("Test setup: users.create permission missing"); + await prisma.role_permissions.create({ + data: { role_id: creatorRoleId, permission_id: perm.id }, + }); + const creator = await prisma.users.create({ + data: { + username: `${N}creator_${stamp}`, + email: `${N}creator_${stamp}@test.local`, + password_hash: + "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali", + first_name: "Creator", + last_name: "Only", + is_active: true, + role_id: creatorRoleId, + }, + }); + creatorToken = token({ + id: creator.id, + username: creator.username, + roleName: creatorRole.name, + }); + + // Target role the caller does NOT hold — its content is irrelevant, the + // guard must strip ANY role assignment from a non-admin create. + const privilegedRole = await prisma.roles.create({ + data: { name: `${N}privileged_${stamp}`, display_name: "Privileged" }, + }); + privilegedRoleId = privilegedRole.id; +}); + +afterAll(async () => { + if (app) await app.close(); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("POST /users role escalation guard (audit M10)", () => { + it("strips role_id when the caller is not admin", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/admin/users", + headers: { + Authorization: `Bearer ${creatorToken}`, + "Content-Type": "application/json", + }, + payload: { + username: `${N}victim_${stamp}`, + email: `${N}victim_${stamp}@test.local`, + password: "Heslo12345", + first_name: "Victim", + last_name: "User", + role_id: privilegedRoleId, + }, + }); + expect(res.statusCode).toBe(201); + + const created = await prisma.users.findFirst({ + where: { username: `${N}victim_${stamp}` }, + }); + expect(created).not.toBeNull(); + expect(created?.role_id).not.toBe(privilegedRoleId); + }); + + it("keeps role assignment for an admin caller", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/admin/users", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { + username: `${N}byadmin_${stamp}`, + email: `${N}byadmin_${stamp}@test.local`, + password: "Heslo12345", + first_name: "ByAdmin", + last_name: "User", + role_id: privilegedRoleId, + }, + }); + expect(res.statusCode).toBe(201); + + const created = await prisma.users.findFirst({ + where: { username: `${N}byadmin_${stamp}` }, + }); + expect(created?.role_id).toBe(privilegedRoleId); + }); +}); diff --git a/src/__tests__/warehouse-date-filter.test.ts b/src/__tests__/warehouse-date-filter.test.ts new file mode 100644 index 0000000..4a0d577 --- /dev/null +++ b/src/__tests__/warehouse-date-filter.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, 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 { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import warehouseRoutes from "../routes/admin/warehouse"; + +/** + * Pinning tests for audit finding M7: date_from/date_to filters on warehouse + * lists must cover the FULL local calendar days. created_at holds local-time + * instants; the old bounds (gte/lte `new Date("YYYY-MM-DD")` = UTC midnight) + * excluded the whole inclusive end day and the first 1-2 morning hours of + * the start day. + */ + +const N = "wh_datefilter_"; +const DAY = "2098-04-08"; + +let app: ReturnType; +let adminToken: string; +let earlyId: number; // 00:30 local on DAY — start-day morning window +let middayId: number; // 09:00 local on DAY +let lateId: number; // 23:30 local on DAY — end-day coverage +let nextDayId: number; // 00:30 local the day AFTER — must stay excluded + +async function cleanup() { + await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } }); +} + +beforeAll(async () => { + await cleanup(); + + app = Fastify({ logger: false }); + await app.register(cookie); + await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + app.addHook("onRequest", securityHeaders); + await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); + + // Local-time instants on the filtered day (year 2098 keeps real data out). + const early = await prisma.sklad_receipts.create({ + data: { notes: `${N}early`, created_at: new Date(2098, 3, 8, 0, 30, 0) }, + }); + earlyId = early.id; + const midday = await prisma.sklad_receipts.create({ + data: { notes: `${N}midday`, created_at: new Date(2098, 3, 8, 9, 0, 0) }, + }); + middayId = midday.id; + const late = await prisma.sklad_receipts.create({ + data: { notes: `${N}late`, created_at: new Date(2098, 3, 8, 23, 30, 0) }, + }); + lateId = late.id; + const nextDay = await prisma.sklad_receipts.create({ + data: { notes: `${N}nextday`, created_at: new Date(2098, 3, 9, 0, 30, 0) }, + }); + nextDayId = nextDay.id; +}); + +afterAll(async () => { + if (app) await app.close(); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("warehouse receipts date filter (audit M7)", () => { + it("a single-day range returns every receipt of that local day and nothing else", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/receipts?date_from=${DAY}&date_to=${DAY}&limit=100`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id); + + expect(ids).toContain(earlyId); // 00:30 — dropped by the old UTC from-bound + expect(ids).toContain(middayId); // 09:00 — dropped by the old lte to-bound + expect(ids).toContain(lateId); // 23:30 — dropped by the old lte to-bound + expect(ids).not.toContain(nextDayId); // next day stays out + }); +}); diff --git a/src/__tests__/warehouse-fk-400.test.ts b/src/__tests__/warehouse-fk-400.test.ts new file mode 100644 index 0000000..bcaac08 --- /dev/null +++ b/src/__tests__/warehouse-fk-400.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, 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 { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import warehouseRoutes from "../routes/admin/warehouse"; + +/** + * Pinning tests for audit finding M8: warehouse receipts/issues create+update + * must pre-validate FK existence (supplier, item, location, project, explicit + * batch) and return Czech 400s — a dangling id otherwise raises P2003 at + * Prisma and surfaces as a generic 500. The document modules and trips.ts + * pre-validate this way; warehouse omitted the guard. + */ + +const N = "wh_fk400_"; +const MISSING_ID = 99999999; + +let app: ReturnType; +let adminToken: string; +let projectId: number; +let itemId: number; +let draftReceiptId: number; + +async function cleanup() { + await prisma.sklad_issue_lines.deleteMany({ + where: { issue: { notes: { contains: N } } }, + }); + await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } }); + await prisma.sklad_batches.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + await prisma.sklad_receipt_lines.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } }); + await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } }); + await prisma.projects.deleteMany({ where: { name: { startsWith: N } } }); +} + +beforeAll(async () => { + await cleanup(); + + app = Fastify({ logger: false }); + await app.register(cookie); + await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + app.addHook("onRequest", securityHeaders); + await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); + + const project = await prisma.projects.create({ + data: { name: `${N}project`, status: "active" }, + }); + projectId = project.id; + + const item = await prisma.sklad_items.create({ + data: { name: `${N}item`, unit: "ks" }, + }); + itemId = item.id; + + // In-stock batch so issue tests get past availability checks. + const receipt = await prisma.sklad_receipts.create({ + data: { notes: `${N}stock`, status: "CONFIRMED" }, + }); + const line = await prisma.sklad_receipt_lines.create({ + data: { + receipt_id: receipt.id, + item_id: itemId, + quantity: 10, + unit_price: 5, + }, + }); + await prisma.sklad_batches.create({ + data: { + item_id: itemId, + receipt_line_id: line.id, + quantity: 10, + original_qty: 10, + unit_price: 5, + received_at: new Date(2026, 0, 1, 12, 0, 0), + is_consumed: false, + }, + }); + + // DRAFT receipt for the PUT test. + const draft = await prisma.sklad_receipts.create({ + data: { + notes: `${N}draft`, + status: "DRAFT", + items: { create: [{ item_id: itemId, quantity: 1, unit_price: 5 }] }, + }, + }); + draftReceiptId = draft.id; +}); + +afterAll(async () => { + if (app) await app.close(); + await cleanup(); + await prisma.$disconnect(); +}); + +function post(url: string, payload: unknown) { + return app.inject({ + method: "POST", + url, + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: payload as Record, + }); +} + +describe("warehouse FK pre-validation (audit M8)", () => { + it("POST /receipts with a nonexistent item_id returns 400, not 500", async () => { + const res = await post("/api/admin/warehouse/receipts", { + notes: `${N}r1`, + items: [{ item_id: MISSING_ID, quantity: 1, unit_price: 10 }], + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("nenalezen"); + }); + + it("POST /receipts with a nonexistent supplier_id returns 400, not 500", async () => { + const res = await post("/api/admin/warehouse/receipts", { + notes: `${N}r2`, + supplier_id: MISSING_ID, + items: [{ item_id: itemId, quantity: 1, unit_price: 10 }], + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("nenalezen"); + }); + + it("POST /receipts with a nonexistent location_id returns 400, not 500", async () => { + const res = await post("/api/admin/warehouse/receipts", { + notes: `${N}r3`, + items: [ + { + item_id: itemId, + quantity: 1, + unit_price: 10, + location_id: MISSING_ID, + }, + ], + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("nenalezen"); + }); + + it("PUT /receipts/:id with a nonexistent item_id returns 400, not 500", async () => { + const res = await app.inject({ + method: "PUT", + url: `/api/admin/warehouse/receipts/${draftReceiptId}`, + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json", + }, + payload: { + notes: `${N}draft`, + items: [{ item_id: MISSING_ID, quantity: 1, unit_price: 10 }], + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("nenalezen"); + }); + + it("POST /issues with a nonexistent project_id returns 400, not 500", async () => { + const res = await post("/api/admin/warehouse/issues", { + notes: `${N}i1`, + project_id: MISSING_ID, + items: [{ item_id: itemId, quantity: 1 }], + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("nenalezen"); + }); + + it("POST /issues with a nonexistent explicit batch_id returns 400, not 500", async () => { + const res = await post("/api/admin/warehouse/issues", { + notes: `${N}i2`, + project_id: projectId, + items: [{ item_id: itemId, quantity: 1, batch_id: MISSING_ID }], + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain("nenalezen"); + }); + + it("valid receipt create still succeeds", async () => { + const res = await post("/api/admin/warehouse/receipts", { + notes: `${N}ok`, + items: [{ item_id: itemId, quantity: 2, unit_price: 10 }], + }); + expect(res.statusCode).toBe(201); + }); +}); diff --git a/src/__tests__/warehouse-inventory-confirm.test.ts b/src/__tests__/warehouse-inventory-confirm.test.ts new file mode 100644 index 0000000..0d85a39 --- /dev/null +++ b/src/__tests__/warehouse-inventory-confirm.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import prisma from "../config/database"; +import { confirmInventorySession } from "../services/warehouse.service"; + +/** + * Pinning tests for audit finding C3: confirmInventorySession must be atomic. + * A session whose item list contains a valid surplus line BEFORE a deficit + * line that cannot be satisfied (no batches) must fail WITHOUT committing the + * surplus item's corrective receipt/batch — and retries must not accumulate + * phantom stock. + */ + +const N = "wh_invconf_"; + +let itemAId: number; // surplus line (processed first — lower line id) +let itemBId: number; // deficit line with no batches -> selectFifoBatches throws +let sessionId: number; + +/** Corrective receipts carry generated notes without our prefix — collect + * them via their lines' items before deleting. */ +async function cleanup() { + const lines = await prisma.sklad_receipt_lines.findMany({ + where: { item: { name: { contains: N } } }, + select: { receipt_id: true }, + }); + const receiptIds = [...new Set(lines.map((l) => l.receipt_id))]; + await prisma.sklad_batches.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + await prisma.sklad_receipt_lines.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + if (receiptIds.length > 0) { + await prisma.sklad_receipts.deleteMany({ + where: { id: { in: receiptIds } }, + }); + } + await prisma.sklad_inventory_lines.deleteMany({ + where: { session: { notes: { contains: N } } }, + }); + await prisma.sklad_inventory_sessions.deleteMany({ + where: { notes: { contains: N } }, + }); + await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } }); +} + +beforeAll(async () => { + await cleanup(); + + const itemA = await prisma.sklad_items.create({ + data: { name: `${N}surplus_item`, unit: "ks" }, + }); + itemAId = itemA.id; + const itemB = await prisma.sklad_items.create({ + data: { name: `${N}deficit_item`, unit: "ks" }, + }); + itemBId = itemB.id; + + // Surplus line created first => lower id => processed first by the confirm + // loop. Deficit line for an item with NO batches forces the FIFO throw. + const session = await prisma.sklad_inventory_sessions.create({ + data: { + notes: `${N}session`, + items: { + create: [ + { item_id: itemAId, system_qty: 0, actual_qty: 5, difference: 5 }, + { item_id: itemBId, system_qty: 3, actual_qty: 0, difference: -3 }, + ], + }, + }, + }); + sessionId = session.id; +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("confirmInventorySession atomicity (audit C3)", () => { + it("rolls back the surplus corrective receipt when a later deficit line fails", async () => { + const result = await confirmInventorySession(sessionId); + + // The confirm must fail — item B has nothing to consume. + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.status).toBe(400); + expect(result.error).toContain(`ID ${itemBId}`); + } + + // ...and item A's surplus writes must NOT have been committed. + const receiptLines = await prisma.sklad_receipt_lines.findMany({ + where: { item_id: itemAId }, + }); + expect(receiptLines).toHaveLength(0); + + const batches = await prisma.sklad_batches.findMany({ + where: { item_id: itemAId }, + }); + expect(batches).toHaveLength(0); + + // The session itself stays DRAFT and unnumbered either way. + const session = await prisma.sklad_inventory_sessions.findUnique({ + where: { id: sessionId }, + }); + expect(session?.status).toBe("DRAFT"); + expect(session?.session_number).toBeNull(); + }); + + it("does not accumulate phantom stock when the failed confirm is retried", async () => { + await confirmInventorySession(sessionId); + await confirmInventorySession(sessionId); + + const batches = await prisma.sklad_batches.findMany({ + where: { item_id: itemAId }, + }); + expect(batches).toHaveLength(0); + + const receipts = await prisma.sklad_receipts.findMany({ + where: { notes: { contains: `inventarizace #${sessionId}` } }, + }); + expect(receipts).toHaveLength(0); + }); +}); diff --git a/src/__tests__/warehouse-issue-confirm.test.ts b/src/__tests__/warehouse-issue-confirm.test.ts new file mode 100644 index 0000000..d7db363 --- /dev/null +++ b/src/__tests__/warehouse-issue-confirm.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import prisma from "../config/database"; +import { confirmIssue } from "../services/warehouse.service"; + +/** + * Pinning tests for audit finding C2: confirmIssue must validate duplicate + * lines pointing at the SAME batch against their combined quantity. Two lines + * that each pass the per-line check individually must not decrement the batch + * cumulatively below zero (silent over-issue hidden from stock totals). + * + * The duplicate-lines draft is exactly what POST /issues produces when two + * same-item lines without batch_id FIFO-resolve to the same oldest batch + * (per-line resolution persists nothing between lines), and is equally + * reachable with explicit batch_ids. + */ + +const N = "wh_issueconf_"; + +let projectId: number; +let userId: number; +let itemId: number; +let batch1Id: number; +let overIssueId: number; +let exactIssueId: number; + +async function cleanup() { + await prisma.sklad_issue_lines.deleteMany({ + where: { issue: { notes: { contains: N } } }, + }); + await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } }); + await prisma.sklad_batches.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + await prisma.sklad_receipt_lines.deleteMany({ + where: { item: { name: { contains: N } } }, + }); + await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } }); + await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } }); + await prisma.users + .deleteMany({ where: { username: { startsWith: N } } }) + .catch(() => {}); + await prisma.projects + .deleteMany({ where: { name: { startsWith: N } } }) + .catch(() => {}); +} + +beforeAll(async () => { + await cleanup(); + + const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } }); + if (!adminRole) + throw new Error("Admin role not found — seed the database first"); + + const user = await prisma.users.create({ + data: { + username: `${N}user`, + email: `${N}user@test.local`, + password_hash: + "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", + first_name: "Issue", + last_name: "Confirm", + is_active: true, + role_id: adminRole.id, + }, + }); + userId = user.id; + + const project = await prisma.projects.create({ + data: { name: `${N}project`, status: "active" }, + }); + projectId = project.id; + + const item = await prisma.sklad_items.create({ + data: { name: `${N}item`, unit: "ks" }, + }); + itemId = item.id; + + // Two batches of 8 — total stock 16, so the per-item aggregate check + // passes a combined quantity of 10, but FIFO resolves both lines to the + // OLDER batch B1 which only holds 8. + const receipt = await prisma.sklad_receipts.create({ + data: { notes: `${N}receipt`, status: "CONFIRMED" }, + }); + const line1 = await prisma.sklad_receipt_lines.create({ + data: { + receipt_id: receipt.id, + item_id: itemId, + quantity: 8, + unit_price: 10, + }, + }); + const line2 = await prisma.sklad_receipt_lines.create({ + data: { + receipt_id: receipt.id, + item_id: itemId, + quantity: 8, + unit_price: 10, + }, + }); + const batch1 = await prisma.sklad_batches.create({ + data: { + item_id: itemId, + receipt_line_id: line1.id, + quantity: 8, + original_qty: 8, + unit_price: 10, + received_at: new Date(2026, 0, 1, 12, 0, 0), + is_consumed: false, + }, + }); + batch1Id = batch1.id; + await prisma.sklad_batches.create({ + data: { + item_id: itemId, + receipt_line_id: line2.id, + quantity: 8, + original_qty: 8, + unit_price: 10, + received_at: new Date(2026, 0, 2, 12, 0, 0), + is_consumed: false, + }, + }); + + // Draft mirroring the FIFO resolution: two lines, both on B1, 5 + 5 = 10 > 8. + const overIssue = await prisma.sklad_issues.create({ + data: { + project_id: projectId, + notes: `${N}over_issue`, + items: { + create: [ + { item_id: itemId, batch_id: batch1Id, quantity: 5 }, + { item_id: itemId, batch_id: batch1Id, quantity: 5 }, + ], + }, + }, + }); + overIssueId = overIssue.id; + + // Control draft: duplicate lines whose combined quantity EXACTLY fits B1. + const exactIssue = await prisma.sklad_issues.create({ + data: { + project_id: projectId, + notes: `${N}exact_issue`, + items: { + create: [ + { item_id: itemId, batch_id: batch1Id, quantity: 3 }, + { item_id: itemId, batch_id: batch1Id, quantity: 5 }, + ], + }, + }, + }); + exactIssueId = exactIssue.id; +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("confirmIssue cross-line batch totals (audit C2)", () => { + it("rejects duplicate same-batch lines whose combined quantity exceeds the batch", async () => { + const result = await confirmIssue(overIssueId, userId); + + expect("error" in result).toBe(true); + if ("error" in result) expect(result.status).toBe(400); + + // The batch must be untouched — not driven negative and hidden. + const b1 = await prisma.sklad_batches.findUnique({ + where: { id: batch1Id }, + }); + expect(Number(b1?.quantity)).toBe(8); + expect(b1?.is_consumed).toBe(false); + + const negatives = await prisma.sklad_batches.findMany({ + where: { item_id: itemId, quantity: { lt: 0 } }, + }); + expect(negatives).toHaveLength(0); + + const issue = await prisma.sklad_issues.findUnique({ + where: { id: overIssueId }, + }); + expect(issue?.status).toBe("DRAFT"); + }); + + it("still confirms duplicate same-batch lines whose combined quantity fits", async () => { + const result = await confirmIssue(exactIssueId, userId); + + expect("error" in result).toBe(false); + + const b1 = await prisma.sklad_batches.findUnique({ + where: { id: batch1Id }, + }); + expect(Number(b1?.quantity)).toBe(0); + expect(b1?.is_consumed).toBe(true); + }); +}); diff --git a/src/__tests__/warehouse-items-sort.test.ts b/src/__tests__/warehouse-items-sort.test.ts new file mode 100644 index 0000000..34b4c52 --- /dev/null +++ b/src/__tests__/warehouse-items-sort.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, 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 { config } from "../config/env"; +import { securityHeaders } from "../middleware/security"; +import warehouseRoutes from "../routes/admin/warehouse"; + +/** + * Pinning tests for the verified-adjacent audit finding: GET /items ignores + * the client `sort` param (the WarehouseItems page sends one — default + * item_number) and hardcodes `orderBy: { name: "asc" }` with no `{ id }` + * tiebreak. + */ + +const N = "wh_itemsort_"; + +let app: ReturnType; +let adminToken: string; +// Names sort A→B, item numbers sort the OPPOSITE way, so honoring the sort +// param produces a different first row than the hardcoded name ordering. +let itemAId: number; // name "...a_name", item_number "...Z2" +let itemBId: number; // name "...b_name", item_number "...A1" + +async function cleanup() { + await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } }); +} + +beforeAll(async () => { + await cleanup(); + + app = Fastify({ logger: false }); + await app.register(cookie); + await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" }); + app.addHook("onRequest", securityHeaders); + await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" }); + + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminToken = jwt.sign( + { sub: admin.id, username: admin.username, role: "admin" }, + config.jwt.secret, + { expiresIn: "15m" }, + ); + + const itemA = await prisma.sklad_items.create({ + data: { name: `${N}a_name`, item_number: `${N}Z2`, unit: "ks" }, + }); + itemAId = itemA.id; + const itemB = await prisma.sklad_items.create({ + data: { name: `${N}b_name`, item_number: `${N}A1`, unit: "ks" }, + }); + itemBId = itemB.id; +}); + +afterAll(async () => { + if (app) await app.close(); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("GET /warehouse/items sort param", () => { + it("honors sort=item_number (asc puts the A1 item first)", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/items?search=${N}&sort=item_number&order=asc`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id); + expect(ids).toEqual([itemBId, itemAId]); + }); + + it("defaults to name ordering when no sort is given", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/items?search=${N}`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id); + expect(ids).toEqual([itemAId, itemBId]); + }); + + it("ignores a non-allow-listed sort field (no 500)", async () => { + const res = await app.inject({ + method: "GET", + url: `/api/admin/warehouse/items?search=${N}&sort=total_quantity`, + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/src/admin/components/dashboard/DashQuickActions.tsx b/src/admin/components/dashboard/DashQuickActions.tsx index ccb0a86..8a62a3a 100644 --- a/src/admin/components/dashboard/DashQuickActions.tsx +++ b/src/admin/components/dashboard/DashQuickActions.tsx @@ -179,10 +179,12 @@ export default function DashQuickActions({ }); const result: ApiResult = await response.json(); if (result.success) { - // A new trip changes the trip list and the dashboard vehicle widgets — - // invalidate the broad domains (prefix-matching covers sub-queries). + // A new trip changes the trip list, the dashboard vehicle widgets AND + // the vehicles domain (actual_km moves) — invalidate all three broad + // domains (prefix-matching covers sub-queries). queryClient.invalidateQueries({ queryKey: ["trips"] }); queryClient.invalidateQueries({ queryKey: ["dashboard"] }); + queryClient.invalidateQueries({ queryKey: ["vehicles"] }); setShowTripModal(false); alert.success(result.message ?? "Jízda uložena"); } else { diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index a80b655..0c9a3b7 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -1802,23 +1802,28 @@ export default function InvoiceDetail() { - {isEdit && invoice && hasPermission("invoices.view") && ( - - )} + {/* Drafts have no number → /file 404s; hide the button like + OfferDetail/IssuedOrderDetail do. */} + {isEdit && + invoice && + invoice.invoice_number && + hasPermission("invoices.view") && ( + + )} {/* ── Create mode: two-button save ── */} {!isEdit && ( <> diff --git a/src/admin/pages/Invoices.tsx b/src/admin/pages/Invoices.tsx index f9a351c..239ffc5 100644 --- a/src/admin/pages/Invoices.tsx +++ b/src/admin/pages/Invoices.tsx @@ -248,6 +248,9 @@ export default function Invoices() { } else { setStatsMonth((m) => m - 1); } + // Back to page 1 — the server never clamps, so keeping e.g. page 3 in a + // sparser month shows an empty table (same reset as the filter handlers). + setPage(1); }; const nextMonth = () => { @@ -258,6 +261,7 @@ export default function Invoices() { } else { setStatsMonth((m) => m + 1); } + setPage(1); }; const [deleteConfirm, setDeleteConfirm] = useState<{ diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index f019ef4..7d64d25 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -254,7 +254,7 @@ export default function IssuedOrderDetail() { const editable = !readOnly; const canExport = hasPermission("orders.view"); - const { markClean } = useUnsavedChangesGuard( + const { isDirty, markClean } = useUnsavedChangesGuard( { form, items, sections }, !isEdit || dataReady, ); @@ -506,11 +506,23 @@ export default function IssuedOrderDetail() { else void handleSubmit(); }; - // ─── Status transition (status-ONLY payload — a full-form resubmit on a - // non-editable order now 400s server-side) ─── + // ─── Status transition. With unsaved edits on an editable (sent) order + // the transition routes through handleSubmit so the edits are PERSISTED + // with the status — a status-only payload would silently drop them, and + // the markClean below would re-baseline the guard so even the beforeunload + // warning disappears (the order is then read-only: edits unrecoverable). + // Clean (or non-editable) orders keep the status-only payload — a + // full-form resubmit on a non-editable order 400s server-side. ─── const handleStatusChange = async () => { if (!statusConfirm.status || statusChanging) return; const newStatus = statusConfirm.status; + if (editable && isDirty) { + setStatusConfirm({ show: false, status: null }); + // handleSubmit validates, sends the full payload + status, archives + // the PDF and re-baselines the guard with what was actually persisted. + await handleSubmit(newStatus); + return; + } setStatusChanging(newStatus); try { const result = await statusMutation.mutateAsync({ status: newStatus }); diff --git a/src/admin/pages/IssuedOrders.tsx b/src/admin/pages/IssuedOrders.tsx index de74456..61a4fed 100644 --- a/src/admin/pages/IssuedOrders.tsx +++ b/src/admin/pages/IssuedOrders.tsx @@ -133,6 +133,15 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) { const [status, setStatus] = useState(""); const [supplierFilter, setSupplierFilter] = useState(""); const [page, setPage] = useState(1); + // Reset to page 1 when the parent moves to another month — the kept page + // index would otherwise request e.g. page 2 of a sparser month and render + // an empty list. Adjusted during render (adjust-state-on-prop-change + // pattern); a remount via `key` would also clear search/filters. + const [prevMonthYear, setPrevMonthYear] = useState(`${month}-${year}`); + if (`${month}-${year}` !== prevMonthYear) { + setPrevMonthYear(`${month}-${year}`); + setPage(1); + } // Track first successful load so later refetches (filter/status/page change) // keep the table visible instead of flashing the full-page skeleton. const hasLoadedOnce = useRef(false); diff --git a/src/admin/pages/ReceivedInvoices.tsx b/src/admin/pages/ReceivedInvoices.tsx index cf9b016..6bff231 100644 --- a/src/admin/pages/ReceivedInvoices.tsx +++ b/src/admin/pages/ReceivedInvoices.tsx @@ -16,6 +16,7 @@ import { } from "../utils/formatters"; import { normalizeDateStr } from "../utils/attendanceHelpers"; import useTableSort from "../hooks/useTableSort"; +import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { companySettingsOptions, type CompanySettingsData, @@ -44,6 +45,7 @@ import { StatCard, EmptyState, LoadingState, + Pagination, type DataColumn, type StatCardColor, } from "../ui"; @@ -261,14 +263,33 @@ export default function ReceivedInvoices({ const { data: supplierNames = [] } = useQuery(supplierListOptions()); const companySettings = useQuery(companySettingsOptions()).data; - // List query — auto-refetches when filters change - const listQuery = useQuery( + const [page, setPage] = useState(1); + // Reset to page 1 when the parent moves to another month — the kept page + // index would otherwise request e.g. page 2 of a sparser month and render + // an empty list. Adjusted during render (adjust-state-on-prop-change + // pattern); a remount via `key` would also clear the search field. + const [prevMonthYear, setPrevMonthYear] = useState( + `${statsMonth}-${statsYear}`, + ); + if (`${statsMonth}-${statsYear}` !== prevMonthYear) { + setPrevMonthYear(`${statsMonth}-${statsYear}`); + setPage(1); + } + + // List query — auto-refetches when filters change. Paginated: the server + // caps at 25/page, so without the page param rows 26+ were unreachable. + const { + items: invoices, + pagination, + isPending: listPending, + } = usePaginatedQuery( receivedInvoiceListOptions({ month: statsMonth, year: statsYear, search, sort, order, + page, }), ); @@ -289,13 +310,10 @@ export default function ReceivedInvoices({ }), ); - // Derive list data from query (paginatedJsonQuery returns { data, pagination }) - const invoices = listQuery.data?.data ?? []; - // Track first successful load (used to suppress the skeleton on later // refetches). Set in an effect rather than during render to avoid a // render-phase ref mutation. - const hasData = !!(listQuery.data || statsQuery.data); + const hasData = !!(pagination || statsQuery.data); useEffect(() => { if (hasData) hasLoadedOnce.current = true; }, [hasData]); @@ -336,7 +354,7 @@ export default function ReceivedInvoices({ }, }); - const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current; + const showListSkeleton = listPending && !hasLoadedOnce.current; const [uploadFiles, setUploadFiles] = useState([]); const [uploadMeta, setUploadMeta] = useState([]); @@ -814,7 +832,10 @@ export default function ReceivedInvoices({ setSearch(e.target.value)} + onChange={(e) => { + setSearch(e.target.value); + setPage(1); + }} placeholder="Hledat podle dodavatele nebo čísla faktury..." fullWidth /> @@ -869,6 +890,11 @@ export default function ReceivedInvoices({ )} + {/* Upload Modal */} diff --git a/src/admin/pages/Settings.tsx b/src/admin/pages/Settings.tsx index 06edcf1..3d42024 100644 --- a/src/admin/pages/Settings.tsx +++ b/src/admin/pages/Settings.tsx @@ -334,7 +334,7 @@ export default function Settings() { }, [sysSettingsData, sysFormInitialized]); const saveSystemSettingsMutation = useApiMutation< - typeof sysForm, + Partial, { message?: string; error?: string } >({ url: () => `${API_BASE}/company-settings`, @@ -424,7 +424,24 @@ export default function Settings() { const handleSaveSystemSettings = async () => { try { - await saveSystemSettingsMutation.mutateAsync(sysForm); + // Send ONLY the fields this tab edits. sysForm also carries + // numbering/currency fields captured once at init (sysFormInitialized + // never resets), and the backend applies every defined field — sending + // the whole form would silently revert a Firma-tab numbering edit made + // in the meantime with stale values. + await saveSystemSettingsMutation.mutateAsync({ + break_threshold_hours: sysForm.break_threshold_hours, + break_duration_short: sysForm.break_duration_short, + break_duration_long: sysForm.break_duration_long, + clock_rounding_minutes: sysForm.clock_rounding_minutes, + invoice_alert_email: sysForm.invoice_alert_email, + leave_notify_email: sysForm.leave_notify_email, + smtp_from: sysForm.smtp_from, + smtp_from_name: sysForm.smtp_from_name, + max_login_attempts: sysForm.max_login_attempts, + lockout_minutes: sysForm.lockout_minutes, + max_requests_per_minute: sysForm.max_requests_per_minute, + }); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } diff --git a/src/routes/admin/ai.ts b/src/routes/admin/ai.ts index 7ab1316..0bd5339 100644 --- a/src/routes/admin/ai.ts +++ b/src/routes/admin/ai.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import multipart from "@fastify/multipart"; -import { requirePermission } from "../../middleware/auth"; +import { requirePermission, requireAnyPermission } from "../../middleware/auth"; import { success, error, parseId } from "../../utils/response"; import { parseBody } from "../../schemas/common"; import { @@ -54,10 +54,14 @@ export default async function aiRoutes(app: FastifyInstance): Promise { }, ); - // PUT /api/admin/ai/budget — set the monthly budget (admins have ai.use) + // PUT /api/admin/ai/budget — set the monthly budget. A COMPANY setting + // (budget_usd=0 is a company-wide AI DoS; a huge value removes the cost + // cap), so it is gated like company_settings writes, NOT by ai.use. app.put( "/budget", - { preHandler: requirePermission("ai.use") }, + { + preHandler: requireAnyPermission("settings.company", "settings.system"), + }, async (request: FastifyRequest, reply: FastifyReply) => { const body = parseBody(AiBudgetSchema, request.body); if ("error" in body) return error(reply, body.error, 400); diff --git a/src/routes/admin/audit-log.ts b/src/routes/admin/audit-log.ts index 0dd4dcc..e65c5cf 100644 --- a/src/routes/admin/audit-log.ts +++ b/src/routes/admin/audit-log.ts @@ -42,7 +42,10 @@ export default async function auditLogRoutes( if (query.date_from || query.date_to) { const dateFilter: Record = {}; if (query.date_from) { - const from = new Date(String(query.date_from)); + // LOCAL midnight, symmetric with the date_to side below — a bare + // new Date("YYYY-MM-DD") is UTC midnight (01:00/02:00 Prague) and + // silently excluded events from the first morning hours. + const from = new Date(String(query.date_from) + "T00:00:00"); if (isNaN(from.getTime())) { return error(reply, "Neplatné datum od", 400); } diff --git a/src/routes/admin/customers.ts b/src/routes/admin/customers.ts index 5225d12..619e810 100644 --- a/src/routes/admin/customers.ts +++ b/src/routes/admin/customers.ts @@ -51,7 +51,10 @@ export default async function customersRoutes( where, skip, take: limit, - orderBy: { [sortField]: order }, + // {id} tiebreak: the allow-listed sort columns are non-unique, and + // offset pagination over a tied run reorders between page queries + // (rows duplicate/vanish across pages) without a total order. + orderBy: [{ [sortField]: order }, { id: order }], include: { _count: { select: { quotations: true } } }, }), prisma.customers.count({ where }), diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index 005110a..104db4a 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -408,7 +408,22 @@ export default async function invoicesPdfRoutes( const issueDateStr = invoice.issue_date ? localDateStr(new Date(invoice.issue_date)) : undefined; - const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0; + // CNB may be unreachable (or the issue date never cached). The rate + // only feeds the CZK recap + conversion footnote — degrade by + // omitting them (cnbRate = null) rather than failing the whole + // render AND the NAS archival (expected condition, logged). + let cnbRate: number | null = 1.0; + if (isForeign) { + try { + cnbRate = await getRate(currency, issueDateStr); + } catch (err) { + console.error( + `[invoices-pdf] Kurz ČNB nedostupný pro ${currency} — rekapitulace v Kč vynechána`, + err, + ); + cnbRate = null; + } + } const vatRates = [21, 12, 0]; const vatRecap: Array<{ rate: number; @@ -416,16 +431,18 @@ export default async function invoicesPdfRoutes( vat: number; total: number; }> = []; - for (const rate of vatRates) { - const key = String(rate); - const base = vatSummary[key]?.base ?? 0; - const vat = vatSummary[key]?.vat ?? 0; - vatRecap.push({ - rate, - base: Math.round(base * cnbRate * 100) / 100, - vat: Math.round(vat * cnbRate * 100) / 100, - total: Math.round((base + vat) * cnbRate * 100) / 100, - }); + if (cnbRate !== null) { + for (const rate of vatRates) { + const key = String(rate); + const base = vatSummary[key]?.base ?? 0; + const vat = vatSummary[key]?.vat ?? 0; + vatRecap.push({ + rate, + base: Math.round(base * cnbRate * 100) / 100, + vat: Math.round(vat * cnbRate * 100) / 100, + total: Math.round((base + vat) * cnbRate * 100) / 100, + }); + } } const supp = buildAddressLines(settings, true, t); @@ -503,6 +520,39 @@ export default async function invoicesPdfRoutes( ) .join(""); + // CNB rate unavailable → the CZK figures cannot be computed; omit + // the whole recap table (the payment QR next to it stays). + const recapTableHtml = + cnbRate === null + ? "" + : ` + + + + + + + + + + + + + ${vatRecapHtml} + + ${ + isForeign + ? ` + + + + ` + : "" + } +
${escapeHtml(t.vat_recap)}
${escapeHtml(t.vat_base)}${escapeHtml(t.vat_rate)}${escapeHtml(t.vat_amount)}${escapeHtml(t.vat_with_total)}
+ ${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK +
`; + let vatDetailHtml = ""; if (applyVat) { for (const [rate, data] of Object.entries(vatSummary)) { @@ -1050,33 +1100,7 @@ ${indentCSS}
${qrSvg}
- - - - - - - - - - - - - - ${vatRecapHtml} - - ${ - isForeign - ? ` - - - - ` - : "" - } -
${escapeHtml(t.vat_recap)}
${escapeHtml(t.vat_base)}${escapeHtml(t.vat_rate)}${escapeHtml(t.vat_amount)}${escapeHtml(t.vat_with_total)}
- ${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK -
+ ${recapTableHtml} diff --git a/src/routes/admin/leave-requests.ts b/src/routes/admin/leave-requests.ts index 0f34bb9..549c3b4 100644 --- a/src/routes/admin/leave-requests.ts +++ b/src/routes/admin/leave-requests.ts @@ -15,6 +15,8 @@ import { ReviewLeaveRequestSchema, } from "../../schemas/leave-requests.schema"; import { notifyNewLeaveRequest } from "../../services/leave-notification"; +import { isHoliday } from "../../utils/czech-holidays"; +import { localDateStr } from "../../utils/date"; const VALID_LEAVE_TYPES = ["vacation", "sick", "unpaid"] as const; const VALID_REVIEW_STATUSES = ["approved", "rejected"] as const; @@ -87,12 +89,16 @@ export default async function leaveRequestsRoutes( return error(reply, "Datum do musí být po datu od", 400); } - // Compute business days server-side (matching PHP logic) + // Compute business days server-side — weekends AND Czech public + // holidays excluded (mirrors attendance.service createLeave: a weekday + // holiday is already paid/free, charging it would double-deduct). let businessDays = 0; const current = new Date(dateFrom); while (current <= dateTo) { const day = current.getDay(); - if (day !== 0 && day !== 6) businessDays++; + if (day !== 0 && day !== 6 && !isHoliday(localDateStr(current))) { + businessDays++; + } current.setDate(current.getDate() + 1); } @@ -185,31 +191,19 @@ export default async function leaveRequestsRoutes( } if (status === "approved") { - // --- APPROVAL: create attendance records + update leave balance (matching PHP) --- + // --- APPROVAL: create attendance records + update leave balance --- const leaveType = existing.leave_type as string; const dateFrom = new Date(existing.date_from); const dateTo = new Date(existing.date_to); - // For vacation: re-check balance at approval time - if (leaveType === "vacation") { - const year = dateFrom.getFullYear(); - const balance = await prisma.leave_balances.findFirst({ - where: { user_id: existing.user_id, year }, - }); - const vacTotal = balance ? Number(balance.vacation_total) : 160; - const vacUsed = balance ? Number(balance.vacation_used) : 0; - const vacRemaining = vacTotal - vacUsed; - const totalHours = Number(existing.total_hours) || 0; - if (totalHours > vacRemaining) { - return error( - reply, - `Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`, - 400, - ); - } - } - + // Walk the range once (mirrors attendance.service createLeave): + // weekends AND Czech public holidays are skipped — a weekday holiday + // is already paid/free, charging it would double-deduct — and hours + // accumulate per CALENDAR YEAR so a Dec→Jan request books each day + // against its own year's balance. The stored total_hours is NOT + // trusted (pre-fix requests carry holiday-inflated totals). let totalBusinessDays = 0; + const hoursByYear = new Map(); const current = new Date(dateFrom); const attendanceCreates: Array<{ user_id: number; @@ -221,8 +215,10 @@ export default async function leaveRequestsRoutes( while (current <= dateTo) { const dow = current.getDay(); - if (dow !== 0 && dow !== 6) { + if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) { totalBusinessDays++; + const dayYear = current.getFullYear(); + hoursByYear.set(dayYear, (hoursByYear.get(dayYear) ?? 0) + 8); attendanceCreates.push({ user_id: existing.user_id, shift_date: new Date( @@ -245,6 +241,26 @@ export default async function leaveRequestsRoutes( const totalHours = totalBusinessDays * 8; + // For vacation: re-check the balance at approval time — per year, + // each against ITS OWN remaining entitlement. + if (leaveType === "vacation") { + for (const [year, yearHours] of hoursByYear) { + const balance = await prisma.leave_balances.findFirst({ + where: { user_id: existing.user_id, year }, + }); + const vacTotal = balance ? Number(balance.vacation_total) : 160; + const vacUsed = balance ? Number(balance.vacation_used) : 0; + const vacRemaining = vacTotal - vacUsed; + if (yearHours > vacRemaining) { + return error( + reply, + `Nedostatek dovolené pro rok ${year}. Zbývá ${vacRemaining}h, požadováno ${yearHours}h.`, + 400, + ); + } + } + } + try { await prisma.$transaction(async (tx) => { // Check for duplicate attendance records inside the transaction @@ -264,38 +280,40 @@ export default async function leaveRequestsRoutes( await tx.attendance.createMany({ data: attendanceCreates }); } - // 2. Update leave balance (vacation/sick only — not unpaid) + // 2. Update leave balances (vacation/sick only — not unpaid), + // each calendar year charged separately. if (leaveType === "vacation" || leaveType === "sick") { - const year = dateFrom.getFullYear(); - const existingBalance = await tx.leave_balances.findFirst({ - where: { user_id: existing.user_id, year }, - }); + for (const [year, yearHours] of hoursByYear) { + const existingBalance = await tx.leave_balances.findFirst({ + where: { user_id: existing.user_id, year }, + }); - if (existingBalance) { - const updateData: Record = { - updated_at: new Date(), - }; - if (leaveType === "vacation") { - updateData.vacation_used = - Number(existingBalance.vacation_used) + totalHours; + if (existingBalance) { + const updateData: Record = { + updated_at: new Date(), + }; + if (leaveType === "vacation") { + updateData.vacation_used = + Number(existingBalance.vacation_used) + yearHours; + } else { + updateData.sick_used = + Number(existingBalance.sick_used) + yearHours; + } + await tx.leave_balances.update({ + where: { id: existingBalance.id }, + data: updateData, + }); } else { - updateData.sick_used = - Number(existingBalance.sick_used) + totalHours; + await tx.leave_balances.create({ + data: { + user_id: existing.user_id, + year, + vacation_total: 160, + vacation_used: leaveType === "vacation" ? yearHours : 0, + sick_used: leaveType === "sick" ? yearHours : 0, + }, + }); } - await tx.leave_balances.update({ - where: { id: existingBalance.id }, - data: updateData, - }); - } else { - await tx.leave_balances.create({ - data: { - user_id: existing.user_id, - year, - vacation_total: 160, - vacation_used: leaveType === "vacation" ? totalHours : 0, - sick_used: leaveType === "sick" ? totalHours : 0, - }, - }); } } diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index aa5c0a2..4f3e686 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -141,7 +141,9 @@ export default async function receivedInvoicesRoutes( where, skip, take: limit, - orderBy: { [sortField]: order }, + // {id} tiebreak — non-unique sort keys reorder between page + // queries without it (rows duplicate/vanish across pages). + orderBy: [{ [sortField]: order }, { id: order }], }), prisma.received_invoices.count({ where }), ]); @@ -396,6 +398,19 @@ export default async function receivedInvoicesRoutes( const issueDate = meta.issue_date ? new Date(String(meta.issue_date)) : null; + const dueDate = meta.due_date + ? new Date(String(meta.due_date)) + : null; + // The schema regex can't catch impossible dates ("2098-99-99") — + // an Invalid Date here would derive NaN month/year and fail at + // Prisma only AFTER the NAS save (orphaned file). Reject before + // any side effect. + if (issueDate && isNaN(issueDate.getTime())) { + return error(reply, "Neplatné datum vystavení", 400); + } + if (dueDate && isNaN(dueDate.getTime())) { + return error(reply, "Neplatné datum splatnosti", 400); + } const invoiceMonth = issueDate ? issueDate.getMonth() + 1 : Number(meta.month) || now.getMonth() + 1; @@ -432,10 +447,8 @@ export default async function receivedInvoicesRoutes( currency: meta.currency ? String(meta.currency) : "CZK", vat_rate: vatRate, vat_amount: vatAmount, - issue_date: meta.issue_date - ? new Date(String(meta.issue_date)) - : null, - due_date: meta.due_date ? new Date(String(meta.due_date)) : null, + issue_date: issueDate, + due_date: dueDate, status: "unpaid", notes: meta.notes ? String(meta.notes) : null, uploaded_by: request.authData?.userId, diff --git a/src/routes/admin/users.ts b/src/routes/admin/users.ts index fb40854..4e0347b 100644 --- a/src/routes/admin/users.ts +++ b/src/routes/admin/users.ts @@ -60,6 +60,11 @@ export default async function usersRoutes( if ("error" in parsed) return error(reply, parsed.error, 400); const body = parsed.data; + // Privilege-escalation guard (mirrors PUT): role assignment is + // admin-only. A bare users.create holder could otherwise mint an + // account on a high-privilege role the caller itself lacks. + const callerIsAdmin = request.authData?.roleName === "admin"; + const result = await createUser( { username: body.username, @@ -67,7 +72,7 @@ export default async function usersRoutes( password: body.password, first_name: body.first_name, last_name: body.last_name, - role_id: body.role_id, + role_id: callerIsAdmin ? body.role_id : undefined, is_active: body.is_active, }, request.authData?.roleName ?? undefined, diff --git a/src/routes/admin/warehouse.ts b/src/routes/admin/warehouse.ts index c88dfa3..79873b4 100644 --- a/src/routes/admin/warehouse.ts +++ b/src/routes/admin/warehouse.ts @@ -73,6 +73,90 @@ function numFilter(raw: unknown): number | undefined { return Number.isFinite(n) ? n : undefined; } +/** + * Local-day boundaries for created_at range filters. created_at holds + * LOCAL-time instants, so the from-bound is the day's local midnight — a + * bare new Date("YYYY-MM-DD") is UTC midnight (01:00/02:00 Prague), which + * silently dropped the first morning hours. The to-bound is the NEXT day's + * local midnight, exclusive — `lte` against UTC midnight dropped the entire + * inclusive end day. + */ +function localDayStart(dateStr: string): Date { + return new Date(`${dateStr}T00:00:00`); +} +function localDayEndExclusive(dateStr: string): Date { + const d = new Date(`${dateStr}T00:00:00`); + d.setDate(d.getDate() + 1); + return d; +} + +/** + * FK pre-validation for receipt/issue payloads (mirrors the document + * modules): a dangling id would otherwise raise P2003 at Prisma and surface + * as a generic 500. Returns a Czech message for a clean 400, or null when + * all referenced rows exist. + */ +async function findMissingLineRef( + items: Array<{ item_id: number; location_id?: number | null }>, +): Promise { + for (const line of items) { + const item = await prisma.sklad_items.findUnique({ + where: { id: line.item_id }, + select: { id: true }, + }); + if (!item) return `Položka ID ${line.item_id} nenalezena`; + if (line.location_id != null) { + const location = await prisma.sklad_locations.findUnique({ + where: { id: line.location_id }, + select: { id: true }, + }); + if (!location) return `Lokace ID ${line.location_id} nenalezena`; + } + } + return null; +} + +async function findMissingReceiptRef(body: { + supplier_id?: number | null; + items?: Array<{ item_id: number; location_id?: number | null }>; +}): Promise { + if (body.supplier_id != null) { + const supplier = await prisma.sklad_suppliers.findUnique({ + where: { id: body.supplier_id }, + select: { id: true }, + }); + if (!supplier) return "Dodavatel nenalezen"; + } + return findMissingLineRef(body.items ?? []); +} + +async function findMissingIssueRef(body: { + project_id?: number; + items?: Array<{ + item_id: number; + batch_id?: number | null; + location_id?: number | null; + }>; +}): Promise { + if (body.project_id != null) { + const project = await prisma.projects.findUnique({ + where: { id: body.project_id }, + select: { id: true }, + }); + if (!project) return "Projekt nenalezen"; + } + for (const line of body.items ?? []) { + if (line.batch_id != null) { + const batch = await prisma.sklad_batches.findUnique({ + where: { id: line.batch_id }, + select: { id: true }, + }); + if (!batch) return `Šarže ID ${line.batch_id} nenalezena`; + } + } + return findMissingLineRef(body.items ?? []); +} + // NOTE: this file bundles 8 warehouse sub-entities (~2400 lines). Splitting it // per sub-entity (items/receipts/issues/reservations/inventory/reports) is a // tracked refactor, deferred — see REVIEW_FINDINGS.md. @@ -625,13 +709,22 @@ export default async function warehouseRoutes( "/items", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => { - const { page, limit, skip, search } = parsePagination( + const { page, limit, skip, order, search } = parsePagination( request.query as Record, ); const query = request.query as Record; const categoryFilter = query.category_id ? Number(query.category_id) : undefined; + // Allow-listed DB sort columns (computed columns like total_quantity + // can't be ordered server-side); anything else falls back to name. + const ITEM_SORT_FIELDS = ["name", "item_number", "created_at"]; + const sortField = ITEM_SORT_FIELDS.includes(String(query.sort)) + ? String(query.sort) + : "name"; + // Default stays ASC (the route always sorted name asc); parsePagination + // defaults to desc, so only honor it when the client sent one. + const sortOrder = query.order ? order : "asc"; const where: Record = {}; if (search || categoryFilter !== undefined) { @@ -659,7 +752,9 @@ export default async function warehouseRoutes( where, skip, take: limit, - orderBy: { name: "asc" }, + // {id} tiebreak — name/item_number are non-unique; offset + // pagination needs a total order or rows duplicate/vanish. + orderBy: [{ [sortField]: sortOrder }, { id: sortOrder }], include: { category: { select: { id: true, name: true } }, item_locations: { @@ -1032,10 +1127,14 @@ export default async function warehouseRoutes( where.push({ supplier_id: supplierId }); } if (query.date_from) { - where.push({ created_at: { gte: new Date(String(query.date_from)) } }); + where.push({ + created_at: { gte: localDayStart(String(query.date_from)) }, + }); } if (query.date_to) { - where.push({ created_at: { lte: new Date(String(query.date_to)) } }); + where.push({ + created_at: { lt: localDayEndExclusive(String(query.date_to)) }, + }); } if (search) { where.push({ @@ -1114,6 +1213,9 @@ export default async function warehouseRoutes( const body = parsed.data; const authUserId = request.authData!.userId; + const missingRef = await findMissingReceiptRef(body); + if (missingRef) return error(reply, missingRef, 400); + const receipt = await prisma.sklad_receipts.create({ data: { supplier_id: body.supplier_id ?? null, @@ -1183,6 +1285,9 @@ export default async function warehouseRoutes( return error(reply, "Příjem lze upravit pouze ve stavu DRAFT", 400); } + const missingRef = await findMissingReceiptRef(body); + if (missingRef) return error(reply, missingRef, 400); + const updateData: Record = {}; if (body.supplier_id !== undefined) updateData.supplier_id = body.supplier_id; @@ -1498,10 +1603,14 @@ export default async function warehouseRoutes( where.push({ project_id: projectId }); } if (query.date_from) { - where.push({ created_at: { gte: new Date(String(query.date_from)) } }); + where.push({ + created_at: { gte: localDayStart(String(query.date_from)) }, + }); } if (query.date_to) { - where.push({ created_at: { lte: new Date(String(query.date_to)) } }); + where.push({ + created_at: { lt: localDayEndExclusive(String(query.date_to)) }, + }); } if (search) { where.push({ @@ -1585,6 +1694,11 @@ export default async function warehouseRoutes( const body = parsed.data; const authUserId = request.authData!.userId; + // Before FIFO resolution, so a nonexistent item gets "Položka + // nenalezena" instead of a misleading insufficient-stock message. + const missingRef = await findMissingIssueRef(body); + if (missingRef) return error(reply, missingRef, 400); + // Resolve FIFO batches for items without batch_id const resolvedItems: Array<{ item_id: number; @@ -1704,6 +1818,9 @@ export default async function warehouseRoutes( return error(reply, "Výdej lze upravit pouze ve stavu DRAFT", 400); } + const missingRef = await findMissingIssueRef(body); + if (missingRef) return error(reply, missingRef, 400); + const updateData: Record = {}; if (body.project_id !== undefined) updateData.project_id = body.project_id; @@ -2400,12 +2517,12 @@ export default async function warehouseRoutes( } if (query.date_from) { issueWhere.push({ - created_at: { gte: new Date(String(query.date_from)) }, + created_at: { gte: localDayStart(String(query.date_from)) }, }); } if (query.date_to) { issueWhere.push({ - created_at: { lte: new Date(String(query.date_to)) }, + created_at: { lt: localDayEndExclusive(String(query.date_to)) }, }); } @@ -2491,14 +2608,14 @@ export default async function warehouseRoutes( const issueWhere: Record[] = [{ status: "CONFIRMED" }]; if (query.date_from) { - const dateFrom = new Date(String(query.date_from)); + const dateFrom = localDayStart(String(query.date_from)); receiptWhere.push({ created_at: { gte: dateFrom } }); issueWhere.push({ created_at: { gte: dateFrom } }); } if (query.date_to) { - const dateTo = new Date(String(query.date_to)); - receiptWhere.push({ created_at: { lte: dateTo } }); - issueWhere.push({ created_at: { lte: dateTo } }); + const dateTo = localDayEndExclusive(String(query.date_to)); + receiptWhere.push({ created_at: { lt: dateTo } }); + issueWhere.push({ created_at: { lt: dateTo } }); } const [receipts, issues] = await Promise.all([ diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 44e83c4..0e8da55 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -27,7 +27,10 @@ export const TotpBackupSchema = z.object({ }); export const TotpEnableSchema = z.object({ - secret: z.string().min(1).max(255), + // The secret is stored AES-256-GCM encrypted (base64 of IV+ciphertext+tag) + // in a VarChar(255) column — plaintext over ~164 chars overflows AFTER + // encryption. Real TOTP secrets are 16-32 chars; 64 is generous. + secret: z.string().min(1).max(64), code: z.string().min(1).max(64), password: z.string().max(200).optional(), current_code: z.string().max(64).optional(), diff --git a/src/schemas/common.ts b/src/schemas/common.ts index 9b64aab..0b7a645 100644 --- a/src/schemas/common.ts +++ b/src/schemas/common.ts @@ -110,6 +110,26 @@ export const isoDateString = z.preprocess( .regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD"), ); +/** + * Nullable/optional variant of isoDateString for edit-form date fields: + * "" and null normalize to null (the forms clear date inputs with empty + * strings), undefined stays undefined (update semantics: field untouched). + * Same lenient trailing-time stripping — a local-midnight datetime written + * to a @db.Date column would land a day early (Prisma truncates to the UTC + * date part). + */ +export const nullableIsoDateString = z.preprocess( + (v) => { + if (v === undefined) return undefined; + if (v === null || v === "") return null; + return typeof v === "string" ? v.slice(0, 10) : v; + }, + z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD") + .nullish(), +); + /** HH:MM (24h) time string. Tolerant of a trailing ":ss" seconds component. */ export const timeString = z.preprocess( (v) => diff --git a/src/schemas/customers.schema.ts b/src/schemas/customers.schema.ts index c11d930..f858e47 100644 --- a/src/schemas/customers.schema.ts +++ b/src/schemas/customers.schema.ts @@ -4,10 +4,10 @@ export const CreateCustomerSchema = z.object({ name: z.string().min(1, "Název zákazníka je povinný").max(255), street: z.string().max(255).nullish(), city: z.string().max(255).nullish(), - postal_code: z.string().max(255).nullish(), - country: z.string().max(255).nullish(), - company_id: z.string().max(255).nullish(), - vat_id: z.string().max(255).nullish(), + postal_code: z.string().max(20).nullish(), + country: z.string().max(100).nullish(), + company_id: z.string().max(50).nullish(), + vat_id: z.string().max(50).nullish(), custom_fields: z.array(z.unknown()).max(100).optional(), customer_field_order: z.array(z.string()).optional(), }); @@ -16,10 +16,10 @@ export const UpdateCustomerSchema = z.object({ name: z.string().min(1, "Název zákazníka je povinný").max(255).optional(), street: z.string().max(255).nullish(), city: z.string().max(255).nullish(), - postal_code: z.string().max(255).nullish(), - country: z.string().max(255).nullish(), - company_id: z.string().max(255).nullish(), - vat_id: z.string().max(255).nullish(), + postal_code: z.string().max(20).nullish(), + country: z.string().max(100).nullish(), + company_id: z.string().max(50).nullish(), + vat_id: z.string().max(50).nullish(), custom_fields: z.array(z.unknown()).max(100).optional(), customer_field_order: z.array(z.string()).optional(), }); diff --git a/src/schemas/invoices.schema.ts b/src/schemas/invoices.schema.ts index f69b550..5f02dad 100644 --- a/src/schemas/invoices.schema.ts +++ b/src/schemas/invoices.schema.ts @@ -6,39 +6,40 @@ import { positiveNumberFromForm, nullableIntIdFromForm, booleanFromForm, + nullableIsoDateString, DocumentSectionSchema, } from "./common"; const InvoiceItemSchema = z.object({ - description: z.string().max(8000).nullish(), + description: z.string().max(500).nullish(), item_description: z.string().max(8000).nullish(), quantity: positiveNumberFromForm.default(1), - unit: z.string().max(255).nullish(), + unit: z.string().max(20).nullish(), unit_price: numberFromForm.default(0), vat_rate: numberInRange(0, 100).optional().default(21.0), position: nonNegativeNumberFromForm.optional(), }); export const CreateInvoiceSchema = z.object({ - invoice_number: z.string().max(255).nullish(), + invoice_number: z.string().max(50).nullish(), order_id: nullableIntIdFromForm.nullish(), customer_id: nullableIntIdFromForm.nullish(), status: z.string().max(20).optional().default("draft"), - currency: z.string().max(20).optional().default("CZK"), + currency: z.string().max(10).optional().default("CZK"), vat_rate: numberInRange(0, 100).optional().default(21.0), apply_vat: booleanFromForm.optional().default(true), - payment_method: z.string().max(255).nullish(), - constant_symbol: z.string().max(255).nullish(), + payment_method: z.string().max(50).nullish(), + constant_symbol: z.string().max(20).nullish(), bank_name: z.string().max(255).nullish(), - bank_swift: z.string().max(255).nullish(), - bank_iban: z.string().max(255).nullish(), - bank_account: z.string().max(255).nullish(), - issue_date: z.string().max(255).nullish(), - due_date: z.string().max(255).nullish(), - tax_date: z.string().max(255).nullish(), + bank_swift: z.string().max(20).nullish(), + bank_iban: z.string().max(50).nullish(), + bank_account: z.string().max(50).nullish(), + issue_date: nullableIsoDateString, + due_date: nullableIsoDateString, + tax_date: nullableIsoDateString, issued_by: z.string().max(255).nullish(), - billing_text: z.string().max(8000).nullish(), - language: z.string().max(20).optional().default("cs"), + billing_text: z.string().max(500).nullish(), + language: z.string().max(5).optional().default("cs"), // `notes` was dropped (printed-notes block removed — free-form PDF content // lives in the sections); legacy clients still sending it are silently // stripped by z.object. internal_notes is never printed. @@ -51,24 +52,24 @@ export const CreateInvoiceSchema = z.object({ export const UpdateInvoiceSchema = z.object({ status: z.string().max(20).optional(), - currency: z.string().max(20).optional(), - language: z.string().max(20).optional(), - payment_method: z.string().max(255).nullish(), - constant_symbol: z.string().max(255).nullish(), + currency: z.string().max(10).optional(), + language: z.string().max(5).optional(), + payment_method: z.string().max(50).nullish(), + constant_symbol: z.string().max(20).nullish(), bank_name: z.string().max(255).nullish(), - bank_swift: z.string().max(255).nullish(), - bank_iban: z.string().max(255).nullish(), - bank_account: z.string().max(255).nullish(), + bank_swift: z.string().max(20).nullish(), + bank_iban: z.string().max(50).nullish(), + bank_account: z.string().max(50).nullish(), issued_by: z.string().max(255).nullish(), - billing_text: z.string().max(8000).nullish(), + billing_text: z.string().max(500).nullish(), customer_id: nullableIntIdFromForm.optional(), vat_rate: numberInRange(0, 100).optional(), apply_vat: booleanFromForm.optional(), - issue_date: z.union([z.string().max(255), z.null()]).optional(), - due_date: z.union([z.string().max(255), z.null()]).optional(), - tax_date: z.union([z.string().max(255), z.null()]).optional(), + issue_date: nullableIsoDateString, + due_date: nullableIsoDateString, + tax_date: nullableIsoDateString, internal_notes: z.string().max(8000).nullish(), - paid_date: z.union([z.string().max(255), z.null()]).optional(), + paid_date: nullableIsoDateString, items: z.array(InvoiceItemSchema).optional(), sections: z.array(DocumentSectionSchema).optional(), }); diff --git a/src/schemas/orders.schema.ts b/src/schemas/orders.schema.ts index 73695f9..f2af13a 100644 --- a/src/schemas/orders.schema.ts +++ b/src/schemas/orders.schema.ts @@ -9,10 +9,10 @@ import { } from "./common"; const OrderItemSchema = z.object({ - description: z.string().max(8000).nullish(), + description: z.string().max(500).nullish(), item_description: z.string().max(8000).nullish(), quantity: positiveNumberFromForm.default(1), - unit: z.string().max(255).nullish(), + unit: z.string().max(20).nullish(), unit_price: numberFromForm.default(0), is_included_in_total: booleanFromForm.optional().default(true), position: nonNegativeNumberFromForm.optional(), @@ -27,20 +27,20 @@ const OrderSectionSchema = z.object({ export const CreateOrderFromQuotationSchema = z.object({ quotationId: intIdFromForm, - customerOrderNumber: z.string().max(255).optional().default(""), + customerOrderNumber: z.string().max(100).optional().default(""), }); export const CreateOrderSchema = z.object({ - order_number: z.string().max(255).nullish(), - customer_order_number: z.string().max(255).nullish(), + order_number: z.string().max(50).nullish(), + customer_order_number: z.string().max(100).nullish(), quotation_id: nullableIntIdFromForm.nullish(), customer_id: nullableIntIdFromForm.nullish(), status: z .enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]) .optional() .default("prijata"), - currency: z.string().max(20).optional().default("CZK"), - language: z.string().max(20).optional().default("cs"), + currency: z.string().max(10).optional().default("CZK"), + language: z.string().max(5).optional().default("cs"), exchange_rate: positiveNumberFromForm.optional().default(1.0), scope_title: z.string().max(255).nullish(), scope_description: z.string().max(8000).nullish(), @@ -51,10 +51,10 @@ export const CreateOrderSchema = z.object({ }); export const UpdateOrderSchema = z.object({ - customer_order_number: z.string().max(255).nullish(), + customer_order_number: z.string().max(100).nullish(), status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(), - currency: z.string().max(20).optional(), - language: z.string().max(20).optional(), + currency: z.string().max(10).optional(), + language: z.string().max(5).optional(), scope_title: z.string().max(255).nullish(), scope_description: z.string().max(8000).nullish(), notes: z.string().max(8000).nullish(), diff --git a/src/schemas/received-invoices.schema.ts b/src/schemas/received-invoices.schema.ts index 3a8520e..4c4f6c6 100644 --- a/src/schemas/received-invoices.schema.ts +++ b/src/schemas/received-invoices.schema.ts @@ -1,33 +1,37 @@ import { z } from "zod"; -import { numberInRange, nonNegativeNumberFromForm } from "./common"; +import { + numberInRange, + nonNegativeNumberFromForm, + nullableIsoDateString, +} from "./common"; export const CreateReceivedInvoiceSchema = z.object({ supplier_name: z.string().min(1, "Název dodavatele je povinný").max(255), month: numberInRange(1, 12), year: numberInRange(2000, 2100), - invoice_number: z.string().max(255).nullish(), - description: z.string().max(8000).nullish(), + invoice_number: z.string().max(100).nullish(), + description: z.string().max(500).nullish(), amount: nonNegativeNumberFromForm.optional().default(0), - currency: z.string().max(20).optional().default("CZK"), + currency: z.string().max(3).optional().default("CZK"), vat_rate: numberInRange(0, 100).optional().default(21), vat_amount: nonNegativeNumberFromForm.optional().default(0), - issue_date: z.string().max(255).nullish(), - due_date: z.string().max(255).nullish(), + issue_date: nullableIsoDateString, + due_date: nullableIsoDateString, status: z.enum(["unpaid", "paid"]).optional().default("unpaid"), notes: z.string().max(8000).nullish(), }); export const UpdateReceivedInvoiceSchema = z.object({ supplier_name: z.string().max(255).optional(), - invoice_number: z.string().max(255).nullish(), - description: z.string().max(8000).nullish(), + invoice_number: z.string().max(100).nullish(), + description: z.string().max(500).nullish(), amount: nonNegativeNumberFromForm.optional(), - currency: z.string().max(20).optional(), + currency: z.string().max(3).optional(), vat_rate: numberInRange(0, 100).optional(), vat_amount: nonNegativeNumberFromForm.optional(), - issue_date: z.union([z.string().max(255), z.null()]).optional(), - due_date: z.union([z.string().max(255), z.null()]).optional(), - paid_date: z.union([z.string().max(255), z.null()]).optional(), + issue_date: nullableIsoDateString, + due_date: nullableIsoDateString, + paid_date: nullableIsoDateString, status: z.enum(["unpaid", "paid"]).optional(), notes: z.string().max(8000).nullish(), month: numberInRange(1, 12).optional(), diff --git a/src/schemas/trips.schema.ts b/src/schemas/trips.schema.ts index ad321fa..07134fb 100644 --- a/src/schemas/trips.schema.ts +++ b/src/schemas/trips.schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { intIdFromForm, - nonNegativeNumberFromForm, + nonNegativeIntFromForm, booleanFromForm, isoDateString, } from "./common"; @@ -12,10 +12,12 @@ export const CreateTripSchema = z.object({ // when the client doesn't provide it (see POST /trips handler) user_id: intIdFromForm.optional(), trip_date: isoDateString, - start_km: nonNegativeNumberFromForm, - end_km: nonNegativeNumberFromForm, - route_from: z.string().min(1, "Vyplňte odkud").max(255), - route_to: z.string().min(1, "Vyplňte kam").max(255), + // Int columns — a decimal odometer reading would 500 at Prisma (or + // truncate, corrupting the derived distance and vehicles.actual_km). + start_km: nonNegativeIntFromForm, + end_km: nonNegativeIntFromForm, + route_from: z.string().min(1, "Vyplňte odkud").max(100), + route_to: z.string().min(1, "Vyplňte kam").max(100), is_business: booleanFromForm.optional().default(false), notes: z.string().max(2000).nullish(), }); @@ -24,10 +26,10 @@ export const UpdateTripSchema = z.object({ // trips.vehicle_id is a non-nullable FK — when present it must be a valid id vehicle_id: intIdFromForm.optional(), trip_date: isoDateString.optional(), - start_km: nonNegativeNumberFromForm.optional(), - end_km: nonNegativeNumberFromForm.optional(), - route_from: z.string().min(1, "Vyplňte odkud").max(255).optional(), - route_to: z.string().min(1, "Vyplňte kam").max(255).optional(), + start_km: nonNegativeIntFromForm.optional(), + end_km: nonNegativeIntFromForm.optional(), + route_from: z.string().min(1, "Vyplňte odkud").max(100).optional(), + route_to: z.string().min(1, "Vyplňte kam").max(100).optional(), is_business: booleanFromForm.optional(), notes: z.string().max(2000).nullish(), }); diff --git a/src/schemas/vehicles.schema.ts b/src/schemas/vehicles.schema.ts index 74397e0..1089285 100644 --- a/src/schemas/vehicles.schema.ts +++ b/src/schemas/vehicles.schema.ts @@ -1,13 +1,14 @@ import { z } from "zod"; -import { nonNegativeNumberFromForm } from "./common"; +import { nonNegativeIntFromForm } from "./common"; export const CreateVehicleSchema = z.object({ spz: z.string().min(1, "SPZ je povinná").max(50), name: z.string().min(1, "Název je povinný").max(255), brand: z.string().nullish(), model: z.string().nullish(), - initial_km: nonNegativeNumberFromForm.optional().default(0), - actual_km: nonNegativeNumberFromForm.optional().default(0), + // Int columns — decimals would 500 at Prisma or silently truncate. + initial_km: nonNegativeIntFromForm.optional().default(0), + actual_km: nonNegativeIntFromForm.optional().default(0), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional() @@ -19,8 +20,8 @@ export const UpdateVehicleSchema = z.object({ name: z.string().max(255).optional(), brand: z.string().nullish(), model: z.string().nullish(), - initial_km: nonNegativeNumberFromForm.optional(), - actual_km: nonNegativeNumberFromForm.optional(), + initial_km: nonNegativeIntFromForm.optional(), + actual_km: nonNegativeIntFromForm.optional(), is_active: z .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) .optional(), diff --git a/src/schemas/warehouse.schema.ts b/src/schemas/warehouse.schema.ts index a35004b..01618b8 100644 --- a/src/schemas/warehouse.schema.ts +++ b/src/schemas/warehouse.schema.ts @@ -31,8 +31,8 @@ export type UpdateCategoryInput = z.infer; // stripped by z.object). Limits are DB-aligned. export const CreateSupplierSchema = z.object({ name: z.string().min(1, "Název je povinný").max(255), - ico: z.string().max(255).nullish(), - dic: z.string().max(255).nullish(), + ico: z.string().max(20).nullish(), + dic: z.string().max(20).nullish(), street: z.string().max(255).nullish(), city: z.string().max(255).nullish(), postal_code: z.string().max(20).nullish(), @@ -42,8 +42,8 @@ export const CreateSupplierSchema = z.object({ }); export const UpdateSupplierSchema = z.object({ name: z.string().min(1, "Název je povinný").max(255).optional(), - ico: z.string().max(255).nullish(), - dic: z.string().max(255).nullish(), + ico: z.string().max(20).nullish(), + dic: z.string().max(20).nullish(), street: z.string().max(255).nullish(), city: z.string().max(255).nullish(), postal_code: z.string().max(20).nullish(), diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index a4bd51a..6a9a5cc 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -1753,20 +1753,49 @@ export async function deleteAttendance( requesterId?: number, isAdmin?: boolean, ) { - const existing = await prisma.attendance.findUnique({ where: { id } }); - if (!existing) return { error: "Záznam nenalezen" }; + return prisma.$transaction(async (tx) => { + const existing = await tx.attendance.findUnique({ where: { id } }); + if (!existing) return { error: "Záznam nenalezen" }; - // Ownership is enforced ONLY when a requester is supplied, keeping the - // signature backward compatible (existing callers pass nothing and skip - // the check, behaving exactly as before). - if ( - requesterId !== undefined && - !isAdmin && - existing.user_id !== requesterId - ) { - return { error: "Nemáte oprávnění smazat tento záznam", status: 403 }; - } + // Ownership is enforced ONLY when a requester is supplied, keeping the + // signature backward compatible (existing callers pass nothing and skip + // the check, behaving exactly as before). + if ( + requesterId !== undefined && + !isAdmin && + existing.user_id !== requesterId + ) { + return { error: "Nemáte oprávnění smazat tento záznam", status: 403 }; + } - await prisma.attendance.delete({ where: { id } }); - return { success: true }; + await tx.attendance.delete({ where: { id } }); + + // Inverse of the createLeave charge: deleting a vacation/sick day gives + // its hours back to that day's year balance. Clamped at 0 so deleting + // rows after a manual balance reset cannot drive *_used negative. + if (existing.leave_type === "vacation" || existing.leave_type === "sick") { + const hours = Number(existing.leave_hours ?? 0); + if (hours > 0) { + const field = + existing.leave_type === "vacation" ? "vacation_used" : "sick_used"; + // @db.Date reads back as UTC midnight of the calendar day, so the + // UTC year IS the day's calendar year (no TZ window to misattribute). + const year = existing.shift_date.getUTCFullYear(); + const balance = await tx.leave_balances.findFirst({ + where: { user_id: existing.user_id, year }, + }); + if (balance) { + await tx.leave_balances.update({ + where: { id: balance.id }, + data: { + [field]: Math.max(0, Number(balance[field]) - hours), + updated_at: new Date(), + }, + }); + } + } + } + + return { success: true }; + }); } diff --git a/src/services/auth.ts b/src/services/auth.ts index 5994d9a..d723e93 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -270,17 +270,17 @@ export async function refreshAccessToken( `; const storedToken = tokens[0] ?? null; - // Detected reuse: a token that exists but has ALREADY been rotated - // (replaced_at / replaced_by_hash set) is being presented again. This is - // the classic refresh-token-theft signature — either the legitimate - // client or an attacker is replaying a consumed token. Breach - // containment: revoke the WHOLE token family for that user so both the - // stolen and the still-valid descendant tokens are invalidated, forcing - // a fresh login. - if ( - storedToken && - (storedToken.replaced_at || storedToken.replaced_by_hash) - ) { + // Detected reuse: a token that has ALREADY been rotated (replaced_by_hash + // points at its descendant) is being presented again. This is the classic + // refresh-token-theft signature — either the legitimate client or an + // attacker is replaying a consumed token. Breach containment: revoke the + // WHOLE token family for that user so both the stolen and the still-valid + // descendant tokens are invalidated, forcing a fresh login. + // A token with ONLY replaced_at set was manually TERMINATED (sessions + // DELETE / password change set replaced_at without a descendant) — that + // is an expected dead token, NOT theft: reject it below as plain invalid + // without nuking the user's other sessions. + if (storedToken && storedToken.replaced_by_hash) { const reuseUserId = Number(storedToken.user_id); await tx.refresh_tokens.deleteMany({ where: { user_id: reuseUserId } }); request.log.warn( @@ -289,7 +289,11 @@ export async function refreshAccessToken( return { type: "error", message: "Neplatný refresh token", status: 401 }; } - if (!storedToken || new Date(storedToken.expires_at) < new Date()) { + if ( + !storedToken || + storedToken.replaced_at || + new Date(storedToken.expires_at) < new Date() + ) { return { type: "error", message: "Neplatný refresh token", status: 401 }; } diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index 7e67b40..63b3bd3 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -36,6 +36,43 @@ export function applyPattern( }); } +/** + * Extract the {YYYY}/{YY} year embedded in a generated document number by + * turning its pattern into a capture regex. Returns null when the pattern + * carries no year token or the number doesn't match it (changed pattern, + * legacy data) — callers then fall back to their own year. + */ +export function yearFromGeneratedNumber( + number: string, + pattern: string, + vars: { prefix: string; code: string }, +): number | null { + let yearKind: "YYYY" | "YY" | null = null; + const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regexSrc = pattern.replace( + /\{(\w+)\}|[^{]+/g, + (match, key: string | undefined) => { + if (key === undefined) return escapeRe(match); + if (key === "YYYY" && yearKind === null) { + yearKind = "YYYY"; + return "(\\d{4})"; + } + if (key === "YY" && yearKind === null) { + yearKind = "YY"; + return "(\\d{2})"; + } + if (key === "PREFIX") return escapeRe(vars.prefix); + if (key === "CODE") return escapeRe(vars.code); + if (/^N+$/.test(key)) return "\\d+"; + return escapeRe(match); + }, + ); + if (yearKind === null) return null; + const m = new RegExp(`^${regexSrc}$`).exec(number); + if (!m) return null; + return yearKind === "YYYY" ? Number(m[1]) : 2000 + Number(m[1]); +} + async function getSettings() { return prisma.company_settings.findFirst({ select: { @@ -174,6 +211,14 @@ async function releaseSequence( : ""; const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : ""; + // The number itself embeds the year it was CONSUMED in (the finalize + // year). Prefer that over the caller-supplied year — deleteOffer used to + // pass the created_at year, so a draft created in December and finalized + // in January released against the wrong year's sequence (never matched, + // permanent gap). + const effectiveYear = + yearFromGeneratedNumber(deletedNumber, pattern, { prefix, code }) ?? year; + // Lock the sequence row for the duration of the decrement so a concurrent // getNextSequence (which also takes FOR UPDATE) can't read-modify-write the // same row in between — that interleaving could otherwise lose a decrement or @@ -181,13 +226,13 @@ async function releaseSequence( await prisma.$transaction(async (tx) => { const existing = await tx.$queryRaw>` SELECT last_number FROM number_sequences - WHERE \`type\` = ${type} AND \`year\` = ${year} + WHERE \`type\` = ${type} AND \`year\` = ${effectiveYear} FOR UPDATE `; if (existing.length === 0) return; const highestNumber = applyPattern(pattern, { - year, + year: effectiveYear, prefix, code, seq: existing[0].last_number, @@ -197,7 +242,7 @@ async function releaseSequence( await tx.$executeRaw` UPDATE number_sequences SET \`last_number\` = ${existing[0].last_number - 1} - WHERE \`type\` = ${type} AND \`year\` = ${year} + WHERE \`type\` = ${type} AND \`year\` = ${effectiveYear} `; } }); diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts index c3b0edb..512b4ff 100644 --- a/src/services/plan.service.ts +++ b/src/services/plan.service.ts @@ -424,6 +424,7 @@ async function assertEntryCapAvailable( userId: number, dateFromStr: string, dateToStr: string, + excludeEntryId?: number, ): Promise<{ error: string; status: number } | null> { const from = toDateOnly(dateFromStr); const to = toDateOnly(dateToStr); @@ -433,6 +434,9 @@ async function assertEntryCapAvailable( is_deleted: false, date_from: { lte: to }, date_to: { gte: from }, + // On update, the entry being moved must not count against itself — + // otherwise an unmoved at-cap entry would be uneditable. + ...(excludeEntryId !== undefined ? { id: { not: excludeEntryId } } : {}), }, select: { date_from: true, date_to: true }, }); @@ -587,6 +591,20 @@ export async function updateEntry( return { error: "Datum do musí být stejné nebo po datumu od", status: 400 }; } + // Re-check the per-cell cap when the range changes — expanding onto a day + // already holding MAX_RECORDS_PER_CELL active entries would create a 4th + // that the grid (display-capped at 3) silently hides. The create paths + // guard this; the update path must too. + if (input.date_from || input.date_to) { + const capErr = await assertEntryCapAvailable( + existing.user_id, + fromStr, + toStr, + id, + ); + if (capErr) return capErr; + } + // Only validate the category when it actually changes — so editing just the // note of an entry whose category was later retired/hidden still saves. if (input.category && input.category !== existing.category) { diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 1611f43..f954b38 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -108,6 +108,38 @@ export async function updateProject(id: number, body: Record) { return { error: "Číslo projektu nelze změnit", status: 400 }; } + // FK pre-validation (mirrors createProject): a dangling id would raise + // P2003 at Prisma and surface as a generic 500 — return the same Czech + // 400s as the create path. null still clears the FK. + if (body.customer_id != null) { + const customer = await prisma.customers.findUnique({ + where: { id: Number(body.customer_id) }, + select: { id: true }, + }); + if (!customer) return { error: "Zákazník nenalezen", status: 400 }; + } + if (body.responsible_user_id != null) { + const user = await prisma.users.findUnique({ + where: { id: Number(body.responsible_user_id) }, + select: { id: true }, + }); + if (!user) return { error: "Zodpovědná osoba nenalezena", status: 400 }; + } + if (body.quotation_id != null) { + const quotation = await prisma.quotations.findUnique({ + where: { id: Number(body.quotation_id) }, + select: { id: true }, + }); + if (!quotation) return { error: "Nabídka nenalezena", status: 400 }; + } + if (body.order_id != null) { + const order = await prisma.orders.findUnique({ + where: { id: Number(body.order_id) }, + select: { id: true }, + }); + if (!order) return { error: "Zakázka nenalezena", status: 400 }; + } + const data: Record = { modified_at: new Date() }; const strFields = ["name", "status", "notes"]; for (const f of strFields) diff --git a/src/services/warehouse.service.ts b/src/services/warehouse.service.ts index 9ba4abd..c6bd13a 100644 --- a/src/services/warehouse.service.ts +++ b/src/services/warehouse.service.ts @@ -597,7 +597,21 @@ export async function confirmIssue( issue.items.map((item) => item.batch_id), ); - // Validate each line's batch has enough quantity + // Validate each line's batch has enough quantity — measured against the + // COMBINED quantity of all lines on that batch, not per line. Duplicate + // same-batch lines (the POST /issues FIFO resolver runs per line with + // nothing persisted in between, so two no-batch_id lines for one item + // both resolve to the same oldest batch) would otherwise each pass the + // per-line check and the decrement loop below would drive the batch + // quantity negative — a silent over-issue hidden from stock totals. + const qtyByBatch = new Map(); + for (const item of issue.items) { + qtyByBatch.set( + item.batch_id, + (qtyByBatch.get(item.batch_id) ?? 0) + Number(item.quantity), + ); + } + for (const item of issue.items) { const batch = await tx.sklad_batches.findUnique({ where: { id: item.batch_id }, @@ -623,7 +637,10 @@ export async function confirmIssue( status: 400, }; } - if (Number(batch.quantity) < Number(item.quantity)) { + if ( + Number(batch.quantity) < + (qtyByBatch.get(item.batch_id) ?? Number(item.quantity)) + ) { return { error: `Šarže ID ${item.batch_id} nemá dostatečné množství`, status: 400, @@ -1089,6 +1106,15 @@ export async function cancelReservation( // Inventory Confirm // --------------------------------------------------------------------------- +/** + * Business failure inside the confirm transaction. Thrown (never returned) + * from within the $transaction callback so the whole transaction ROLLS BACK + * — returning a value from an interactive transaction callback COMMITS, which + * would persist an earlier surplus line's corrective receipt while the route + * reports failure (each retry would then add more phantom stock). + */ +class InventoryConfirmError extends Error {} + /** * Confirms an inventory session: * 1. Validate status is DRAFT @@ -1104,6 +1130,22 @@ export async function confirmInventorySession( ): Promise< | { data: { id: number; session_number: string } } | { error: string; status: number } +> { + try { + return await confirmInventorySessionTx(sessionId); + } catch (err) { + if (err instanceof InventoryConfirmError) { + return { error: err.message, status: 400 }; + } + throw err; + } +} + +function confirmInventorySessionTx( + sessionId: number, +): Promise< + | { data: { id: number; session_number: string } } + | { error: string; status: number } > { return prisma.$transaction(async (tx) => { // Lock the inventory session row so two concurrent confirms on the same @@ -1277,10 +1319,10 @@ export async function confirmInventorySession( err instanceof Error ? err.message : "Nedostatečné množství na skladě"; - return { - error: `Inventarizace položky ID ${item.item_id}: ${message}`, - status: 400, - }; + // Throw — NOT return — so earlier lines' writes roll back with us. + throw new InventoryConfirmError( + `Inventarizace položky ID ${item.item_id}: ${message}`, + ); } } } diff --git a/src/utils/html-to-pdf.ts b/src/utils/html-to-pdf.ts index e20f99a..adbd80b 100644 --- a/src/utils/html-to-pdf.ts +++ b/src/utils/html-to-pdf.ts @@ -65,13 +65,14 @@ async function getBrowser(): Promise { export interface HtmlToPdfOptions { /** - * Chromium footerTemplate HTML rendered in the bottom margin of EVERY page - * — the only true repeating footer Puppeteer supports (CSS @page margin - * boxes are not implemented in Chromium). Styles must be inline and the - * font-size explicit (Chromium defaults it to 0); `.pageNumber` / - * `.totalPages` spans are substituted by Chromium. When set, the bottom - * margin grows to make room and an empty headerTemplate suppresses - * Chromium's default date/title header. + * Chromium footerTemplate HTML rendered in the bottom margin of EVERY page. + * Styles must be inline and the font-size explicit (Chromium defaults it + * to 0); `.pageNumber` / `.totalPages` spans are substituted by Chromium. + * When set, the bottom margin grows to make room and an empty + * headerTemplate suppresses Chromium's default date/title header. + * The other valid repeating-footer mechanism: CSS `@page` margin boxes + * (`@bottom-center` + `counter(page)`) render since Chrome 131 (Nov 2024) + * — the offer PDF uses that route and needs no footerTemplate. */ footerTemplate?: string; /** @@ -88,6 +89,28 @@ export interface HtmlToPdfOptions { export async function htmlToPdf( html: string, options: HtmlToPdfOptions = {}, +): Promise { + try { + return await renderPdf(html, options); + } catch (err) { + // The shared browser can die (OOM-kill, crash) between getBrowser()'s + // point-in-time `connected` check and the render — every caller holding + // the stale handle then fails, even though an immediate relaunch would + // succeed. Retry ONCE, and only on the crash signature (browser gone or + // disconnected); a still-connected browser means a genuine render error. + if (browser && browser.connected) throw err; + console.error( + "[html-to-pdf] browser disconnected mid-render — relaunching once", + err, + ); + browser = null; + return renderPdf(html, options); + } +} + +async function renderPdf( + html: string, + options: HtmlToPdfOptions, ): Promise { const b = await getBrowser(); const page = await b.newPage();