fix: resolve all 28 findings from the 2026-06-12 full audit (TDD-pinned)

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 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-12 23:00:19 +02:00
parent 8f74efba97
commit e11765bf0e
56 changed files with 3968 additions and 265 deletions

View File

@@ -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<typeof Fastify>;
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);
});
});

View File

@@ -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));
});
});

View File

@@ -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<typeof Fastify>;
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
});
});

View File

@@ -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("<html><body>retry</body></html>");
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("<html></html>")).rejects.toThrow("Render timeout");
expect(launchMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -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<typeof Fastify>;
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");
});
});

View File

@@ -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<typeof import("../services/exchange-rates")>();
return { ...actual, getRate: vi.fn() };
});
let app: ReturnType<typeof Fastify>;
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");
});
});

View File

@@ -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" });

View File

@@ -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<typeof Fastify>;
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);
});
});

View File

@@ -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<number | null> {
const rows = await prisma.$queryRaw<Array<{ last_number: number }>>`
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);
});
});

View File

@@ -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<typeof Fastify>;
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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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<typeof Fastify>;
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<string, unknown>) {
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");
});
});

View File

@@ -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);
});
}
});

View File

@@ -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");
}
});

View File

@@ -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();
});
});

View File

@@ -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,
);
});
});

View File

@@ -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<typeof Fastify>;
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);
});
});

View File

@@ -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<typeof Fastify>;
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
});
});

View File

@@ -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<typeof Fastify>;
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<string, unknown>,
});
}
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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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<typeof Fastify>;
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);
});
});