fix: 2026-06-09 full-codebase audit hardening

Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -65,15 +65,21 @@ describe("ai.service cost + budget", () => {
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
});
it("sums only the current month's spend", async () => {
it("sums only the current month's spend (excludes prior months)", async () => {
// Measure the baseline so the assertion is robust to any OTHER current-month
// rows that may already exist in the test DB (getMonthSpendUsd sums ALL
// kinds, not just ours). We assert the DELTA we introduce, not an absolute
// upper bound.
const before = await getMonthSpendUsd();
await recordUsage({
userId: null,
kind: KIND,
model: "claude-sonnet-4-6",
inputTokens: 1_000_000,
outputTokens: 0,
}); // $3 this month
// An old row (last year) must NOT count.
}); // +$3 this month
// An old row (year 2000) must NOT count toward the current month.
await prisma.ai_usage.create({
data: {
kind: KIND,
@@ -84,9 +90,11 @@ describe("ai.service cost + budget", () => {
created_at: new Date("2000-01-01T00:00:00Z"),
},
});
const spend = await getMonthSpendUsd();
expect(spend).toBeGreaterThanOrEqual(3);
expect(spend).toBeLessThan(6); // the year-2000 $3 is excluded
const after = await getMonthSpendUsd();
// Only the $3 current-month row moved the needle; the year-2000 $3 is
// excluded. The delta is exactly $3 (not $6).
expect(after - before).toBeCloseTo(3, 6);
});
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
@@ -94,7 +102,11 @@ describe("ai.service cost + budget", () => {
// Under budget → null (allowed)
expect(await assertBudgetAvailable()).toBeNull();
// Push spend over budget for this month, then it must block with 402.
await prisma.ai_usage.create({
// Tag with a unique marker so we can delete EXACTLY this row at the end of
// the test — that keeps the inflated spend from leaking into any other
// current-month test regardless of execution order (the beforeEach kind=KIND
// cleanup would catch it too, but cleaning in-test makes it order-proof).
const overBudget = await prisma.ai_usage.create({
data: {
kind: KIND,
model: "claude-sonnet-4-6",
@@ -104,9 +116,17 @@ describe("ai.service cost + budget", () => {
created_at: new Date(),
},
});
const blocked = await assertBudgetAvailable();
expect(blocked).not.toBeNull();
expect(blocked?.status).toBe(402);
try {
const blocked = await assertBudgetAvailable();
expect(blocked).not.toBeNull();
expect(blocked?.status).toBe(402);
} finally {
// Remove the over-budget row immediately so it cannot inflate
// getMonthSpendUsd for any subsequently-running test.
await prisma.ai_usage
.delete({ where: { id: overBudget.id } })
.catch(() => {});
}
});
it("isConfigured reflects the API key presence", () => {

View File

@@ -1,10 +1,72 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { verifyAccessToken, hashToken } from "../services/auth";
import jwt from "jsonwebtoken";
import { config } from "../config/env";
import prisma from "../config/database";
// Prefix for fixtures so cleanup never touches real data.
const N = "auth_svc_test_";
let testUserId: number;
let testUsername: string;
let testRoleName: string | null;
beforeAll(async () => {
// Clean up any leftover fixtures from a previous failed run.
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
// Use a real (active, unlocked) user from the seeded DB so loadAuthData
// returns a full AuthData. We don't need a specific role — any active user
// works — but we record its role so we can assert roleName precisely.
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`,
email: `${N}user@test.local`,
// DUMMY_HASH used elsewhere in the suite; we never verify a password
// here, only that the token's `sub` resolves to this user.
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Auth",
last_name: "Service",
is_active: true,
role_id: role.id,
},
include: { roles: true },
});
testUserId = user.id;
testUsername = user.username;
testRoleName = user.roles?.name ?? null;
});
afterAll(async () => {
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
});
describe("auth service", () => {
describe("verifyAccessToken", () => {
it("returns AuthData for a valid token signed for a real user", async () => {
const token = jwt.sign(
{ sub: testUserId, username: testUsername, role: testRoleName },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" },
);
const result = await verifyAccessToken(token);
expect(result).not.toBeNull();
expect(result!.userId).toBe(testUserId);
expect(result!.username).toBe(testUsername);
expect(result!.roleName).toBe(testRoleName);
// Permissions resolve to an array (admins get all, others get role perms).
expect(Array.isArray(result!.permissions)).toBe(true);
});
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
const consoleSpy = vi
.spyOn(console, "error")
@@ -35,6 +97,14 @@ describe("auth service", () => {
});
describe("hashToken", () => {
it("matches the known SHA-256 vector for a fixed input", () => {
// SHA-256("hello") — pins the algorithm so a silent change (e.g. to a
// different but still 64-hex-deterministic hash) is caught.
expect(hashToken("hello")).toBe(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
);
});
it("produces deterministic SHA-256 hex output", () => {
const t1 = hashToken("hello");
const t2 = hashToken("hello");

View File

@@ -1,12 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import bcrypt from "bcryptjs";
import { buildApp, extractCookie } from "./helpers";
import prisma from "../config/database";
import { config } from "../config/env";
let app: Awaited<ReturnType<typeof buildApp>>;
// Fixture prefix so cleanup never touches real data.
const N = "auth_test_";
const TEST_USERNAME = `${N}user`;
const TEST_PASSWORD = "Sup3r-Secret-Pw!";
let testUserId: number;
/** Pull every Set-Cookie header line for a given cookie name (raw, with attrs). */
function rawSetCookie(res: { headers: Record<string, any> }, name: string) {
const cookies = res.headers["set-cookie"];
if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies];
return arr.find((c: string) => c.startsWith(`${name}=`));
}
beforeAll(async () => {
app = await buildApp();
// Clean up any leftover fixture from a previous failed run, plus its tokens.
const leftovers = await prisma.users.findMany({
where: { username: { startsWith: N } },
select: { id: true },
});
if (leftovers.length) {
await prisma.refresh_tokens.deleteMany({
where: { user_id: { in: leftovers.map((u) => u.id) } },
});
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
}
const role = await prisma.roles.findFirst();
if (!role) throw new Error("Test setup: no roles found — seed the database");
// Hash the known password with the SAME bcrypt cost the app uses so login's
// bcrypt.compare succeeds.
const passwordHash = await bcrypt.hash(
TEST_PASSWORD,
config.security.bcryptCost,
);
const user = await prisma.users.create({
data: {
username: TEST_USERNAME,
email: `${N}user@test.local`,
password_hash: passwordHash,
first_name: "Auth",
last_name: "Test",
is_active: true,
totp_enabled: false,
role_id: role.id,
},
});
testUserId = user.id;
});
afterAll(async () => {
await prisma.refresh_tokens
.deleteMany({ where: { user_id: testUserId } })
.catch(() => {});
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
@@ -29,6 +90,39 @@ describe("POST /api/admin/login", () => {
});
expect(res.statusCode).toBe(400);
});
it("returns 401 for a valid user with the wrong password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: "definitely-wrong" },
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
});
it("returns 200 with an access token and a refresh cookie on valid credentials", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(typeof body.data.access_token).toBe("string");
expect(body.data.access_token.length).toBeGreaterThan(0);
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
expect(body.data.user.userId).toBe(testUserId);
expect(body.data.user.username).toBe(TEST_USERNAME);
// The refresh token must be set as an httpOnly, SameSite=Strict cookie.
const cookie = extractCookie(res, "refresh_token");
expect(cookie).toBeTruthy();
const rawCookie = rawSetCookie(res, "refresh_token")!;
expect(rawCookie).toMatch(/HttpOnly/i);
expect(rawCookie).toMatch(/SameSite=Strict/i);
});
});
describe("POST /api/admin/refresh", () => {
@@ -39,14 +133,88 @@ describe("POST /api/admin/refresh", () => {
});
expect(res.statusCode).toBe(401);
});
it("returns 401 for an invalid/unknown refresh token", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: "not-a-real-token" },
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
});
it("rotates a valid refresh cookie into a new access token + new refresh cookie", async () => {
// First log in to obtain a valid refresh cookie.
const loginRes = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
expect(loginRes.statusCode).toBe(200);
const originalRefresh = extractCookie(loginRes, "refresh_token")!;
expect(originalRefresh).toBeTruthy();
const refreshRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: originalRefresh },
});
expect(refreshRes.statusCode).toBe(200);
const body = refreshRes.json();
expect(body.success).toBe(true);
expect(typeof body.data.access_token).toBe("string");
expect(body.data.user.userId).toBe(testUserId);
// The refresh token must be ROTATED: a new cookie value, different from
// the one we presented.
const rotated = extractCookie(refreshRes, "refresh_token");
expect(rotated).toBeTruthy();
expect(rotated).not.toBe(originalRefresh);
// Reuse-detection: presenting the now-consumed original token must fail.
const reuseRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: originalRefresh },
});
expect(reuseRes.statusCode).toBe(401);
});
});
describe("POST /api/admin/logout", () => {
it("clears refresh token cookie", async () => {
it("clears the refresh token cookie", async () => {
// Log in so logout has a real token to delete.
const loginRes = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
const refresh = extractCookie(loginRes, "refresh_token")!;
const res = await app.inject({
method: "POST",
url: "/api/admin/logout",
cookies: { refresh_token: refresh },
});
expect(res.statusCode).toBeLessThan(500);
expect(res.statusCode).toBe(200);
// The response must actively clear the cookie — an expiry in the past
// and/or an empty value (clearCookie sets Max-Age=0 / Expires in the past).
const raw = rawSetCookie(res, "refresh_token");
expect(raw).toBeTruthy();
const cleared =
/Max-Age=0/i.test(raw!) ||
/Expires=Thu, 01 Jan 1970/i.test(raw!) ||
/^refresh_token=;/.test(raw!);
expect(cleared).toBe(true);
// The deleted token must no longer be usable for refresh.
const reuseRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: refresh },
});
expect(reuseRes.statusCode).toBe(401);
});
});

View File

@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { UpdateCustomerSchema } from "../schemas/customers.schema";
import {
CreateCustomerSchema,
UpdateCustomerSchema,
} from "../schemas/customers.schema";
describe("UpdateCustomerSchema", () => {
it("rejects empty name", () => {
@@ -16,4 +19,87 @@ describe("UpdateCustomerSchema", () => {
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
expect(result.success).toBe(true);
});
it("rejects a name longer than 255 chars", () => {
const result = UpdateCustomerSchema.safeParse({ name: "x".repeat(256) });
expect(result.success).toBe(false);
});
it("accepts nullable address/identity fields set to null", () => {
const result = UpdateCustomerSchema.safeParse({
street: null,
city: null,
postal_code: null,
country: null,
company_id: null,
vat_id: null,
});
expect(result.success).toBe(true);
});
});
describe("CreateCustomerSchema", () => {
it("requires a name", () => {
const result = CreateCustomerSchema.safeParse({ street: "Main St" });
expect(result.success).toBe(false);
});
it("accepts a full valid payload with all optional fields", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
street: "Main St 1",
city: "Praha",
postal_code: "11000",
country: "CZ",
company_id: "12345678",
vat_id: "CZ12345678",
custom_fields: [{ label: "Note", value: "VIP" }],
customer_field_order: ["name", "street"],
});
expect(result.success).toBe(true);
});
it("accepts custom_fields as an array of arbitrary objects", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: [
{ label: "a", value: 1 },
{ nested: { deep: true } },
"raw string",
],
});
expect(result.success).toBe(true);
});
it("rejects custom_fields that is not an array", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: { not: "an array" },
});
expect(result.success).toBe(false);
});
it("rejects custom_fields longer than the 100-item cap", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: Array.from({ length: 101 }, (_, i) => ({ i })),
});
expect(result.success).toBe(false);
});
it("rejects customer_field_order entries that are not strings", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
customer_field_order: ["name", 5],
});
expect(result.success).toBe(false);
});
it("rejects an over-length nullable field (vat_id > 255)", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
vat_id: "x".repeat(256),
});
expect(result.success).toBe(false);
});
});

View File

@@ -32,3 +32,37 @@ describe("env validation", () => {
expect(config.db.url).toBeTruthy();
});
});
describe("env validation — failure path", () => {
// The config singleton runs its validation at module-load time and is then
// cached by the module system, so we cannot re-import it with bad env to
// observe a throw without complex module-registry resetting. Instead we test
// the exact predicates config/env.ts uses to reject bad input — this is the
// logic that protects the boot, and the loaded config above proves the
// happy path. (Predicates kept in sync with src/config/env.ts.)
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
it("rejects a too-short / non-hex JWT_SECRET", () => {
expect(HEX64_RE.test("short")).toBe(false);
expect(HEX64_RE.test("a".repeat(63))).toBe(false); // one char short
expect(HEX64_RE.test("z".repeat(64))).toBe(false); // 64 chars, not hex
// The real config's secret must pass the very same check.
expect(HEX64_RE.test(config.jwt.secret)).toBe(true);
});
it("rejects an out-of-range or non-numeric PORT", () => {
const portValid = (p: number) => !Number.isNaN(p) && p >= 1 && p <= 65535;
expect(portValid(parseInt("0", 10))).toBe(false);
expect(portValid(parseInt("70000", 10))).toBe(false);
expect(portValid(parseInt("notaport", 10))).toBe(false); // NaN
expect(portValid(config.port)).toBe(true);
});
it("rejects a non-positive ACCESS_TOKEN_EXPIRY", () => {
const expiryValid = (n: number) => !Number.isNaN(n) && n > 0;
expect(expiryValid(0)).toBe(false);
expect(expiryValid(-1)).toBe(false);
expect(expiryValid(parseInt("oops", 10))).toBe(false); // NaN
expect(expiryValid(config.jwt.accessTokenExpiry)).toBe(true);
});
});

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { toCzk, getRate } from "../services/exchange-rates";
import {
toCzk,
getRate,
__resetRateCacheForTest,
} from "../services/exchange-rates";
// Mock global fetch
const mockFetch = vi.fn();
@@ -8,6 +12,11 @@ global.fetch = mockFetch;
describe("exchange-rates", () => {
beforeEach(() => {
mockFetch.mockReset();
// The rate cache is module-level and persists across tests. Without this
// reset, the first test to populate "today" would short-circuit every
// later mockFetch resolution (cache hit), making assertions order-dependent
// and asserting stale rates. Reset it so each test gets a clean fetch.
__resetRateCacheForTest();
});
describe("toCzk", () => {
@@ -43,6 +52,21 @@ describe("exchange-rates", () => {
const result = await toCzk(100, "EUR");
expect(result).toBe(2500);
});
it("honours amount != 1 (currency quoted per 100 units)", async () => {
// CNB quotes some currencies (e.g. HUF, JPY) per 100 units: the `rate`
// is for `amount` units, so the per-unit rate is rate/amount. Here the
// per-unit rate is 250/100 = 2.5 CZK; converting 100 units → 250 CZK.
// If the service ignored `amount` it would compute 100*250 = 25000.
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
}),
});
const result = await toCzk(100, "HUF");
expect(result).toBe(250);
});
});
describe("getRate", () => {
@@ -60,5 +84,17 @@ describe("exchange-rates", () => {
});
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
});
it("returns the per-unit rate for amount != 1", async () => {
// rate 250 for amount 100 → per-unit rate 2.5 (CZK per 1 unit).
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
}),
});
const result = await getRate("HUF");
expect(result).toBe(2.5);
});
});
});

View File

@@ -13,13 +13,31 @@ export async function buildApp() {
return app;
}
export function extractCookie(response: any, name: string): string | undefined {
/**
* Minimal structural shape of the only thing extractCookie needs from a
* response — its `set-cookie` header. Matches Fastify's `LightMyRequestResponse`
* (from `app.inject`) and a plain supertest response without coupling to either.
*/
interface ResponseWithCookies {
headers: Record<string, string | string[] | number | undefined>;
}
export function extractCookie(
response: ResponseWithCookies,
name: string,
): string | undefined {
const cookies = response.headers["set-cookie"];
if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies];
for (const c of arr) {
if (typeof c !== "string") continue;
if (c.startsWith(`${name}=`)) {
return c.split(";")[0].split("=")[1];
// The value is everything from the first "=" up to the first ";".
// Split ONLY on the first "=" so a base64/JWT value containing "=" (e.g.
// padding) is not truncated — `split("=")[1]` would drop everything after
// the second "=".
const pair = c.split(";")[0];
return pair.slice(pair.indexOf("=") + 1);
}
}
return undefined;

View File

@@ -1,20 +1,45 @@
import { describe, it, expect } from "vitest";
import { Prisma } from "@prisma/client";
import { invoiceTotalWithVat } from "../services/invoices.service";
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
// for `Number(decimal)`) is actually caught.
const dec = (n: number | string) => new Prisma.Decimal(n);
// Helper so each test reads as a small declarative table of lines.
function buildInvoice(opts: {
applyVat: boolean;
vatRate: number | null;
currency?: string;
items: Array<{
quantity: number | null;
unit_price: number | null;
vat_rate: number | null;
}>;
}) {
return {
apply_vat: opts.applyVat,
vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
currency: opts.currency ?? "CZK",
invoice_items: opts.items.map((i) => ({
quantity: i.quantity === null ? null : dec(i.quantity),
unit_price: i.unit_price === null ? null : dec(i.unit_price),
vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
})),
};
}
describe("invoiceTotalWithVat", () => {
it("calculates subtotal without VAT when apply_vat is false", () => {
const inv = {
apply_vat: false,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: [
{
quantity: { toNumber: () => 2 },
unit_price: { toNumber: () => 100 },
vat_rate: { toNumber: () => 21 },
},
],
};
const inv = buildInvoice({
applyVat: false,
vatRate: 21,
items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
});
expect(invoiceTotalWithVat(inv)).toBe(200);
});
@@ -24,32 +49,72 @@ describe("invoiceTotalWithVat", () => {
// Total VAT = 7.00 * 3 = 21.00
// Subtotal = 33.33 * 3 = 99.99
// Total = 99.99 + 21.00 = 120.99
const inv = {
apply_vat: true,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: Array.from({ length: 3 }, () => ({
quantity: { toNumber: () => 1 },
unit_price: { toNumber: () => 33.33 },
vat_rate: { toNumber: () => 21 },
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: Array.from({ length: 3 }, () => ({
quantity: 1,
unit_price: 33.33,
vat_rate: 21,
})),
};
});
expect(invoiceTotalWithVat(inv)).toBe(120.99);
});
it("handles null quantity and unit_price gracefully", () => {
const inv = {
apply_vat: true,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: [
{
quantity: { toNumber: () => 0 },
unit_price: { toNumber: () => 0 },
vat_rate: { toNumber: () => 21 },
},
it("treats a null quantity (and null unit_price) as 0 for that line", () => {
// The line is genuinely null here (not 0): quantity?.toNumber() === undefined
// -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
// line proves the null line contributes nothing while the rest still totals.
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [
{ quantity: null, unit_price: null, vat_rate: 21 },
{ quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
],
};
});
expect(invoiceTotalWithVat(inv)).toBe(121);
});
it("returns 0 when every line is null", () => {
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [{ quantity: null, unit_price: null, vat_rate: null }],
});
expect(invoiceTotalWithVat(inv)).toBe(0);
});
it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
// Line 1: 2 x 100 @ 21% -> base 200, vat 42
// Line 2: 1 x 50 @ 12% -> base 50, vat 6
// Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice
// default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
// Subtotal = 350, VAT = 63, Total = 413
const inv = buildInvoice({
applyVat: true,
vatRate: 15,
items: [
{ quantity: 2, unit_price: 100, vat_rate: 21 },
{ quantity: 1, unit_price: 50, vat_rate: 12 },
{ quantity: 1, unit_price: 100, vat_rate: 0 },
],
});
expect(invoiceTotalWithVat(inv)).toBe(413);
});
it("handles a negative (discount) line correctly", () => {
// Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
// Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
// Subtotal = 800, VAT = 168, Total = 968
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [
{ quantity: 1, unit_price: 1000, vat_rate: 21 },
{ quantity: 1, unit_price: -200, vat_rate: 21 },
],
});
expect(invoiceTotalWithVat(inv)).toBe(968);
});
});

View File

@@ -25,6 +25,15 @@ const baseOrder = {
exchange_rate: 1,
};
/**
* Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently
* passed the test when a create failed) while still narrowing the union via the
* `in` operator so the success-branch fields stay typed.
*/
function failResult(res: unknown): never {
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
}
describe("createOrder (manual, optional linked project)", () => {
it("creates an order AND a project sharing one number when create_project=true", async () => {
const res = await createOrder({
@@ -32,9 +41,8 @@ describe("createOrder (manual, optional linked project)", () => {
scope_title: "Ruční zakázka",
create_project: true,
});
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
expect(res.project_id).toBeTruthy();
const project = await prisma.projects.findUnique({
where: { id: res.project_id! },
@@ -46,9 +54,8 @@ describe("createOrder (manual, optional linked project)", () => {
it("creates only the order when create_project=false", async () => {
const res = await createOrder({ ...baseOrder, create_project: false });
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
expect(res.project_id).toBeUndefined();
});
});
@@ -56,19 +63,25 @@ describe("createOrder (manual, optional linked project)", () => {
describe("shared pool coherence (tracking)", () => {
it("order → standalone project → order produce three distinct shared numbers", async () => {
const o1 = await createOrder({ ...baseOrder, create_project: true });
if ("id" in o1) {
createdOrderIds.push(o1.id);
if (o1.project_id) createdProjectIds.push(o1.project_id);
}
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
if ("project_number" in p) createdProjectIds.push(p.id);
const o2 = await createOrder({ ...baseOrder, create_project: false });
if ("id" in o2) createdOrderIds.push(o2.id);
if (!("id" in o1)) failResult(o1);
createdOrderIds.push(o1.id!);
if (o1.project_id) createdProjectIds.push(o1.project_id);
const n1 = "id" in o1 ? o1.order_number : null;
const np = "project_number" in p ? p.project_number : null;
const n2 = "id" in o2 ? o2.order_number : null;
expect(new Set([n1, np, n2]).size).toBe(3);
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
if (!("project_number" in p)) failResult(p);
createdProjectIds.push(p.id);
const o2 = await createOrder({ ...baseOrder, create_project: false });
if (!("id" in o2)) failResult(o2);
createdOrderIds.push(o2.id!);
// All three numbers must be present (non-null) AND distinct.
expect(o1.order_number).toBeTruthy();
expect(p.project_number).toBeTruthy();
expect(o2.order_number).toBeTruthy();
expect(
new Set([o1.order_number, p.project_number, o2.order_number]).size,
).toBe(3);
});
});
@@ -78,8 +91,7 @@ describe("createProject (manual, standalone)", () => {
name: "Test projekt",
status: "aktivni",
});
expect("project_number" in result).toBe(true);
if (!("project_number" in result)) return;
if (!("project_number" in result)) failResult(result);
createdProjectIds.push(result.id);
expect(typeof result.project_number).toBe("string");
expect(result.project_number!.length).toBeGreaterThan(0);
@@ -112,9 +124,8 @@ describe("createOrder with price line item + attachment", () => {
},
],
});
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const items = await prisma.order_items.findMany({
where: { order_id: res.id },
});
@@ -122,21 +133,25 @@ describe("createOrder with price line item + attachment", () => {
expect(Number(items[0].unit_price)).toBe(12345);
});
it("stores an uploaded PO attachment", async () => {
const buf = Buffer.from("%PDF-1.4 fake");
it("stores an uploaded PO attachment with the exact bytes", async () => {
const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí");
const res = await createOrder(
{ ...baseOrder, create_project: false },
buf,
"po.pdf",
);
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const order = await prisma.orders.findUnique({
where: { id: res.id },
select: { attachment_name: true, attachment_data: true },
});
expect(order?.attachment_name).toBe("po.pdf");
// Verify the stored content/size, not just truthiness: round-trip the
// bytes and compare length + value against what we uploaded.
expect(order?.attachment_data).toBeTruthy();
const stored = Buffer.from(order!.attachment_data!);
expect(stored.length).toBe(buf.length);
expect(stored.equals(buf)).toBe(true);
});
});

View File

@@ -1,4 +1,7 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { NasFileManager } from "../services/nas-file-manager";
describe("NasFileManager path traversal", () => {
@@ -53,3 +56,73 @@ describe("NasFileManager path traversal", () => {
});
});
});
/**
* Positive-path coverage: a legitimate (non-traversal) path inside a real
* project folder is ACCEPTED — it passes resolveProjectPath validation and
* reaches the underlying fs op (success → null). Without this, a
* "reject-everything" regression in the traversal guard would still pass every
* rejection test above. Uses the constructor basePath seam + a temp dir so it
* is deterministic even where the real NAS (Z:) is not mounted.
*/
describe("NasFileManager accepts legitimate paths", () => {
let tmpBase: string;
let nas: NasFileManager;
const NUM = "29990001";
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-accept-"));
nas = new NasFileManager(tmpBase);
// Create a real project folder <number>_<name> so findProjectFolder resolves.
nas.createProjectFolder(NUM, "Test");
});
afterEach(() => {
fs.rmSync(tmpBase, { recursive: true, force: true });
});
it("deletes a legitimate file inside the project folder (path accepted)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "doc.pdf"), "hello");
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(true);
const result = await nas.deleteItem(NUM, "doc.pdf");
// null = success: the path was accepted and the file actually removed.
expect(result).toBeNull();
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(false);
});
it("moves a legitimate file to a legitimate destination (both accepted)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
const result = await nas.moveItem(NUM, "from.pdf", "to.pdf");
expect(result).toBeNull();
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(false);
expect(fs.existsSync(path.join(folder, "to.pdf"))).toBe(true);
});
it("a traversal delete is rejected AND deletes nothing (folder intact)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "keep.pdf"), "keep");
const result = await nas.deleteItem(NUM, "../keep.pdf");
expect(result).toContain("Neplatná cesta");
// The guard rejected the path before any fs op — nothing was removed.
expect(fs.existsSync(path.join(folder, "keep.pdf"))).toBe(true);
});
it("rejects a traversal DESTINATION even when the source is legitimate", async () => {
// With a real project folder, the source `from.pdf` resolves cleanly, so
// this isolates the DESTINATION check: `../escape.pdf` must be rejected and
// the source must stay put (no move outside the project folder).
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
const result = await nas.moveItem(NUM, "from.pdf", "../escape.pdf");
expect(result).toContain("Neplatná cesta");
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(true);
// Nothing escaped the project folder.
expect(fs.existsSync(path.join(tmpBase, "escape.pdf"))).toBe(false);
});
});

View File

@@ -9,6 +9,49 @@ import {
} from "../services/numbering.service";
import prisma from "../config/database";
const YEAR = new Date().getFullYear();
/**
* Assert two consecutive document numbers (a) keep an identical format / year
* prefix and (b) increment by exactly one — without hard-coding which
* `company_settings` pattern is configured.
*
* Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of
* "trailing digit run" is ambiguous. Instead we find the longest common prefix
* of the two strings (the unchanged format/year portion) and parse the
* remaining differing suffixes as integers — these are the rendered sequences.
*/
function assertStrictIncrement(num1: string, num2: string) {
expect(num1).not.toBe(num2);
expect(num2.length).toBe(num1.length); // same width ⇒ same format
let i = 0;
while (i < num1.length && num1[i] === num2[i]) i++;
const commonPrefix = num1.slice(0, i);
// The format/year portion is shared and non-empty (the numbers don't differ
// from the very first character).
expect(commonPrefix.length).toBeGreaterThan(0);
const seq1 = Number(num1.slice(i));
const seq2 = Number(num2.slice(i));
expect(Number.isNaN(seq1)).toBe(false);
expect(Number.isNaN(seq2)).toBe(false);
expect(seq2).toBe(seq1 + 1); // strict +1, not merely !==
}
/**
* Seed the (type, year) sequence row to a high `last_number` so the next
* generated numbers land far above any real order/quotation in the test DB —
* the generator's skip-taken retry then never fires and the strict +1
* assertion is deterministic.
*/
async function seedSequence(type: string, lastNumber: number) {
await prisma.number_sequences.deleteMany({ where: { type } });
await prisma.number_sequences.create({
data: { type, year: YEAR, last_number: lastNumber },
});
}
describe("generateSharedNumber", () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
@@ -24,10 +67,13 @@ describe("generateSharedNumber", () => {
expect(num.length).toBeGreaterThan(0);
});
it("increments on consecutive calls", async () => {
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
// Seed high so neither generated number collides with a real order/project
// → the skip-taken retry never fires and the increment is strictly +1.
await seedSequence("shared", 900000);
const num1 = await generateSharedNumber();
const num2 = await generateSharedNumber();
expect(num1).not.toBe(num2);
assertStrictIncrement(num1, num2);
});
});
@@ -46,10 +92,11 @@ describe("generateOfferNumber", () => {
expect(num.length).toBeGreaterThan(0);
});
it("increments on consecutive calls", async () => {
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
await seedSequence("offer", 900000);
const num1 = await generateOfferNumber();
const num2 = await generateOfferNumber();
expect(num1).not.toBe(num2);
assertStrictIncrement(num1, num2);
});
});
@@ -77,9 +124,17 @@ describe("previewSharedNumber", () => {
});
it("skips a number already taken when the sequence counter lags behind", async () => {
// Fresh counter → preview points at the first sequence number. With the
// skip-taken logic this is guaranteed free, so we can safely claim it.
// Seed the counter HIGH so the preview lands far above any real
// order/project in the test DB. Without this, a real row could already
// hold the first sequence number — making the create collide on the unique
// column, or making `second !== first` pass for the wrong reason (because
// the counter happened to already point past `first`).
await seedSequence("shared", 900000);
const first = await previewSharedNumber();
// Preconditions: the seeded preview really is free (so the create below is
// safe) and is exactly what the generator would assign next.
expect(await isSharedNumberTaken(first)).toBe(false);
const project = await prisma.projects.create({
data: { project_number: first, name: "test-preview-skip-taken" },
});
@@ -117,9 +172,14 @@ describe("previewOfferNumber", () => {
});
it("skips a number already taken when the sequence counter lags behind", async () => {
// Fresh counter → preview points at the first sequence number; with the
// skip-taken logic it's guaranteed free, so we can claim it.
// Seed HIGH so the preview lands above any real quotation in the test DB
// (see the shared-number test for the rationale): the create can't collide
// and the skip-advance is genuinely exercised rather than passing because a
// real row already advanced the preview.
await seedSequence("offer", 900000);
const first = await previewOfferNumber();
expect(await isOfferNumberTaken(first)).toBe(false);
const quotation = await prisma.quotations.create({
data: { quotation_number: first },
});

View File

@@ -7,6 +7,7 @@ import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import planRoutes from "../routes/admin/plan";
import { localDateStr } from "../utils/date";
import {
resolveCell,
resolveGrid,
@@ -31,6 +32,14 @@ import {
const N = "wh_plan_";
let adminUserId: number;
// A DEDICATED, distinct non-admin user for the service-level employee-scoping
// tests (and as the second employee in the multi-user bulk test). Creating our
// own user — instead of grabbing "the second user in the DB" — guarantees it is
// never accidentally the admin (which would make `toEqual([])` pass for the
// wrong reason on a single-user DB), and that it is genuinely distinct so the
// 2-employee aggregate assertion can't collapse to 1.
let scopeUserId: number;
let scopeRoleId: number;
let noPermUserId: number;
beforeAll(async () => {
@@ -43,11 +52,44 @@ beforeAll(async () => {
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
// For list-scoping tests: any non-admin user that is NOT the admin we use
// for create tests. Just pick the second user.
const allUsers = await prisma.users.findMany({ take: 2 });
noPermUserId =
allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id;
// Dedicated non-admin user + role for scoping/multi-user tests.
const stamp = Date.now();
const role = await prisma.roles.create({
data: {
name: `scope_plan_${stamp}`,
display_name: "Plan Scope Test",
},
});
scopeRoleId = role.id;
const user = await prisma.users.create({
data: {
username: `scope_plan_${stamp}`,
first_name: "Scope",
last_name: "User",
email: `scope_plan_${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: role.id,
is_active: true,
},
});
scopeUserId = user.id;
if (scopeUserId === adminUserId)
throw new Error("scope user must be distinct from admin");
});
afterAll(async () => {
// Clean up the dedicated scope user/role created above. Wrapped in catch so a
// leftover FK (none expected — its rows are note-prefixed and removed) can't
// mask real failures.
if (scopeUserId)
await prisma.users
.deleteMany({ where: { id: scopeUserId } })
.catch(() => {});
if (scopeRoleId)
await prisma.roles
.deleteMany({ where: { id: scopeRoleId } })
.catch(() => {});
});
beforeEach(async () => {
@@ -355,6 +397,24 @@ describe("plan.service.assertNotPastDate", () => {
const result = assertNotPastDate("2000-01-01", true);
expect(result).toBeNull();
});
it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => {
// Deterministic without mocking the clock: compute "today" exactly the way
// the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in
// config/env.ts). The boundary is the bug-prone path — an off-by-one here
// would wrongly reject editing today's plan.
const today = localDateStr(new Date());
expect(assertNotPastDate(today, false)).toBeNull();
});
it("rejects YESTERDAY (local Prague date) as a past date", () => {
const d = new Date();
d.setDate(d.getDate() - 1); // local-time yesterday
const yesterday = localDateStr(d);
const result = assertNotPastDate(yesterday, false);
expect(result).not.toBeNull();
expect(result?.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
@@ -521,7 +581,7 @@ describe("plan.service.updateEntry", () => {
);
expect("data" in updated).toBe(true);
if ("data" in updated) {
expect(updated.data.note).toBe(`${N}updated`);
expect((updated.data as any).note).toBe(`${N}updated`);
expect((updated.oldData as any).note).toBe(`${N}original`);
}
});
@@ -686,7 +746,7 @@ describe("plan.service.updateOverride", () => {
);
expect("data" in updated).toBe(true);
if ("data" in updated) {
expect(updated.data.note).toBe(`${N}updated`);
expect((updated.data as any).note).toBe(`${N}updated`);
expect((updated.oldData as any).note).toBe(`${N}original`);
}
});
@@ -761,9 +821,13 @@ describe("plan.service.listEntries (employee scoping)", () => {
);
const rows = await listEntries(
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
noPermUserId,
scopeUserId, // dedicated distinct non-admin actor (never the admin)
false, // not admin
);
// A non-admin actor querying ANOTHER user's data is scoped to its own
// user_id, so it sees none of the admin's entries. This can only be
// [] for the right reason because scopeUserId !== adminUserId (asserted
// in beforeAll).
expect(rows).toEqual([]);
});
});
@@ -782,7 +846,7 @@ describe("plan.service.listOverrides (employee scoping)", () => {
);
const rows = await listOverrides(
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
noPermUserId,
scopeUserId, // dedicated distinct non-admin actor (never the admin)
false,
);
expect(rows).toEqual([]);
@@ -875,9 +939,11 @@ describe("plan.service.bulkCreateEntries", () => {
});
it("aggregates across multiple employees", async () => {
// Two genuinely distinct employees (admin + the dedicated scope user) so
// `users` can't collapse to 1 if the DB happened to have a single user.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId, noPermUserId],
user_ids: [adminUserId, scopeUserId],
date_from: "2096-08-03", // Friday
date_to: "2096-08-03",
include_weekends: true,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import {
listPlanCategories,
@@ -12,10 +12,28 @@ import {
const created: number[] = [];
// Every test category's slug starts with "test_" AND contains "_zz" (labels all
// read "Test … ZZ"). Cleaning by this pattern — not just by in-memory ids —
// means a previous crashed run can't leave a `test_kategorie_zz` row behind that
// makes the dedupe test wrongly assert `_3`/`_4` instead of `_2`. The narrow
// pattern avoids touching real seed categories (work/other/…).
async function cleanupByPrefix() {
await prisma.plan_categories.deleteMany({
where: { key: { startsWith: "test_", contains: "_zz" } },
});
}
beforeAll(async () => {
await cleanupByPrefix();
});
afterAll(async () => {
if (created.length) {
await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
}
// Belt-and-suspenders: also remove anything matching the slug prefix in case
// a test threw before pushing its id into `created`.
await cleanupByPrefix();
await prisma.$disconnect();
});
@@ -24,6 +42,13 @@ describe("slugifyCategory", () => {
expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
expect(slugifyCategory("Příprava")).toBe("priprava");
});
it("collapses an all-symbol label to an empty slug", () => {
// No alphanumerics survive → empty string. createPlanCategory's uniqueKey
// then falls back to "kategorie" so the row is still creatable.
expect(slugifyCategory("***")).toBe("");
expect(slugifyCategory(" / ")).toBe("");
});
});
describe("plan category service", () => {

View File

@@ -23,4 +23,29 @@ describe("vatFromGross (VAT contained in a gross total)", () => {
it("returns 0 for a zero amount", () => {
expect(vatFromGross(0, 21)).toBe(0);
});
it("rounds the cent UP when the raw VAT's third decimal forces a carry", () => {
// 115.50 @ 21% -> raw 20.045454... the third decimal (5) rounds the cent
// up to 20.05. Pinned EXACTLY (not toBeCloseTo) so a truncate-instead-of-
// round regression (which would yield 20.04) fails.
expect(vatFromGross(115.5, 21)).toBe(20.05);
// 95.70 @ 21% -> raw 16.609090... rounds up to 16.61.
expect(vatFromGross(95.7, 21)).toBe(16.61);
});
it("rounds the cent DOWN when below the half-cent", () => {
// 100.40 @ 21% -> raw 17.424793... rounds down to 17.42.
expect(vatFromGross(100.4, 21)).toBe(17.42);
});
it("pins the result to the exact stored 2-dp value (not just close)", () => {
// The DB column is Decimal(_, 2). vatFromGross must already return a value
// with no third-decimal float drift. Strict equality + a 2-dp self-check,
// NOT toBeCloseTo, so a 3-dp regression (e.g. 210.0000001) would fail.
const v = vatFromGross(1210, 21);
expect(v).toBe(210); // exact, not ~210
expect(v).toBe(Math.round(v * 100) / 100); // genuinely 2-dp clean
// The real-world figure too: pinned exactly, replacing the toBeCloseTo above.
expect(vatFromGross(22542.91, 21)).toBe(3912.41);
});
});

View File

@@ -1,8 +1,21 @@
import { describe, it, expect } from "vitest";
import { CreateOrderSchema } from "../schemas/orders.schema";
import { CreateQuotationSchema } from "../schemas/offers.schema";
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema";
import {
ReceiptItemSchema,
CreateSupplierSchema,
} from "../schemas/warehouse.schema";
import { UpdateCompanySettingsSchema } from "../schemas/settings.schema";
describe("Zod NaN rejection", () => {
// These schemas now build on the shared form-coercion helpers in
// `src/schemas/common.ts` (numberFromForm / numberInRange / intIdFromForm / …).
// HTTP bodies arrive as strings (multipart) or numbers (JSON), so the helpers
// must BOTH (a) coerce a valid numeric STRING → number and (b) reject a NaN
// string. The original suite only asserted (b); these tests pin (a) as well.
describe("Zod form coercion + NaN rejection", () => {
describe("CreateOrderSchema", () => {
it("rejects NaN string in quantity", () => {
const result = CreateOrderSchema.safeParse({
@@ -27,6 +40,21 @@ describe("Zod NaN rejection", () => {
});
expect(result.success).toBe(true);
});
it("coerces a valid numeric STRING quantity/unit_price to a number", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ quantity: "2", unit_price: "100.5" }],
});
expect(result.success).toBe(true);
if (result.success) {
const item = result.data.items![0];
expect(item.quantity).toBe(2);
expect(typeof item.quantity).toBe("number");
expect(item.unit_price).toBe(100.5);
expect(typeof item.unit_price).toBe("number");
}
});
});
describe("CreateQuotationSchema", () => {
@@ -46,6 +74,18 @@ describe("Zod NaN rejection", () => {
expect(result.success).toBe(true);
});
it("coerces a valid numeric STRING vat_rate to a number", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "21",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.vat_rate).toBe(21);
expect(typeof result.data.vat_rate).toBe("number");
}
});
it("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
@@ -53,5 +93,215 @@ describe("Zod NaN rejection", () => {
});
expect(result.success).toBe(false);
});
it("coerces a valid numeric STRING item quantity/unit_price", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
items: [{ quantity: "3", unit_price: "250" }],
});
expect(result.success).toBe(true);
if (result.success) {
const item = result.data.items![0];
expect(item.quantity).toBe(3);
expect(item.unit_price).toBe(250);
}
});
});
// The remaining schemas were the ones flagged as accepting NaN before the
// shared helpers landed. Assert both the coercion and the rejection paths.
describe("CreateTripSchema", () => {
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
const result = CreateTripSchema.safeParse({
vehicle_id: "5",
trip_date: "2026-01-15",
start_km: "100",
end_km: "150.5",
route_from: "Praha",
route_to: "Brno",
});
expect(result.success).toBe(true);
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(typeof result.data.start_km).toBe("number");
}
});
it("rejects a NaN string in start_km", () => {
const result = CreateTripSchema.safeParse({
vehicle_id: 5,
trip_date: "2026-01-15",
start_km: "not-a-number",
end_km: 150,
route_from: "Praha",
route_to: "Brno",
});
expect(result.success).toBe(false);
});
it("rejects a NaN / non-positive string in vehicle_id (FK)", () => {
const result = CreateTripSchema.safeParse({
vehicle_id: "abc",
trip_date: "2026-01-15",
start_km: 100,
end_km: 150,
route_from: "Praha",
route_to: "Brno",
});
expect(result.success).toBe(false);
});
});
describe("CreateReceivedInvoiceSchema", () => {
it("coerces numeric STRING month / year / amount to numbers", () => {
const result = CreateReceivedInvoiceSchema.safeParse({
supplier_name: "ACME s.r.o.",
month: "6",
year: "2026",
amount: "1210.50",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.month).toBe(6);
expect(result.data.year).toBe(2026);
expect(result.data.amount).toBe(1210.5);
expect(typeof result.data.amount).toBe("number");
}
});
it("rejects a NaN string in amount", () => {
const result = CreateReceivedInvoiceSchema.safeParse({
supplier_name: "ACME s.r.o.",
month: 6,
year: 2026,
amount: "not-money",
});
expect(result.success).toBe(false);
});
it("rejects an out-of-range month (numberInRange 1-12)", () => {
const result = CreateReceivedInvoiceSchema.safeParse({
supplier_name: "ACME s.r.o.",
month: 13,
year: 2026,
});
expect(result.success).toBe(false);
});
it("rejects a NaN string in month", () => {
const result = CreateReceivedInvoiceSchema.safeParse({
supplier_name: "ACME s.r.o.",
month: "bad",
year: 2026,
});
expect(result.success).toBe(false);
});
});
describe("warehouse ReceiptItemSchema", () => {
it("coerces numeric STRING item_id / quantity / unit_price to numbers", () => {
const result = ReceiptItemSchema.safeParse({
item_id: "7",
quantity: "12.5",
unit_price: "99",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.item_id).toBe(7);
expect(result.data.quantity).toBe(12.5);
expect(result.data.unit_price).toBe(99);
expect(typeof result.data.quantity).toBe("number");
}
});
it("rejects a NaN string in quantity", () => {
const result = ReceiptItemSchema.safeParse({
item_id: 7,
quantity: "lots",
unit_price: 99,
});
expect(result.success).toBe(false);
});
it("rejects a non-positive quantity (positiveNumberFromForm)", () => {
const result = ReceiptItemSchema.safeParse({
item_id: 7,
quantity: 0,
unit_price: 99,
});
expect(result.success).toBe(false);
});
it("rejects a NaN / non-positive string in item_id (FK)", () => {
const result = ReceiptItemSchema.safeParse({
item_id: "abc",
quantity: 5,
unit_price: 99,
});
expect(result.success).toBe(false);
});
});
});
// Regression tests for the 2026-06-09 audit fix pass: the schema hardening
// must NOT reject input the app legitimately sends.
describe("schema hardening — does not reject previously-valid input", () => {
it("settings: optional email fields accept an empty string (un-filled)", () => {
const r = UpdateCompanySettingsSchema.safeParse({
invoice_alert_email: "",
leave_notify_email: "",
smtp_from: "",
});
expect(r.success).toBe(true);
});
it("settings: a valid email is accepted, a malformed one rejected", () => {
expect(
UpdateCompanySettingsSchema.safeParse({ smtp_from: "a@b.cz" }).success,
).toBe(true);
expect(
UpdateCompanySettingsSchema.safeParse({ smtp_from: "not-an-email" })
.success,
).toBe(false);
});
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => {
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success,
).toBe(true);
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" })
.success,
).toBe(true);
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" })
.success,
).toBe(false);
});
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
const base = {
vehicle_id: 1,
start_km: 100,
end_km: 120,
route_from: "A",
route_to: "B",
};
// Plain date-only (from the date picker).
const a = CreateTripSchema.safeParse({ ...base, trip_date: "2026-06-09" });
expect(a.success).toBe(true);
// The edit form re-submits what the API serialized for a @db.Date column
// via the Date.toJSON override ("YYYY-MM-DDT00:00:00") — must still pass and
// normalize to the date-only value.
const b = UpdateTripSchema.safeParse({ trip_date: "2026-06-09T00:00:00" });
expect(b.success).toBe(true);
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
// Genuinely malformed dates are still rejected.
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
false,
);
});
});

View File

@@ -1,2 +1,44 @@
import dotenv from "dotenv";
dotenv.config({ path: ".env.test" });
const ENV_TEST_PATH = ".env.test";
// vitest.config.ts already loads .env.test with override:true; we reload here so
// the setup file is self-contained when run directly.
dotenv.config({ path: ENV_TEST_PATH });
/**
* Safety guard: the suite hits a REAL MySQL database (no Prisma mocks) and
* MUTATES it, so it must never run against a dev/production DB. We require the
* configured database name to contain "test" (the committed `.env.test` points
* at the throwaway `app_test`); anything else is a HARD FAILURE before any test
* runs, so a stray DATABASE_URL can't silently corrupt dev/prod data.
*/
function extractDbName(url: string | undefined): string | null {
if (!url) return null;
try {
// mysql://user:pass@host:3306/dbname?params → "dbname"
const afterAuthority = url.replace(/^[a-z]+:\/\/[^/]+\//i, "");
const dbName = afterAuthority.split("?")[0].split("/")[0];
return dbName || null;
} catch {
return null;
}
}
const dbUrl = process.env.DATABASE_URL;
const dbName = extractDbName(dbUrl);
if (!dbUrl) {
throw new Error(
"[test-setup] DATABASE_URL is not set. The suite mutates a real database — " +
`point ${ENV_TEST_PATH} at a dedicated TEST database (e.g. app_test).`,
);
}
if (dbName == null || !/test/i.test(dbName)) {
throw new Error(
`[test-setup] Refusing to run: DATABASE_URL targets database "${dbName ?? "unknown"}", ` +
`whose name does not contain "test". The suite mutates the database — point ` +
`${ENV_TEST_PATH} at a throwaway TEST database (e.g. app_test), never dev/production.`,
);
}

View File

@@ -627,19 +627,22 @@ describe("Item CRUD", () => {
expect(body.data.name).toBe(`${N}item1_updated`);
});
it("deactivates an item (soft delete)", async () => {
it("hard-deletes an item with no stock (row is gone, 404 afterwards)", async () => {
// The current API HARD-deletes the item — the route does
// `prisma.sklad_items.delete`, NOT an `is_active=false` soft toggle. The
// test name reflects that real behavior so a future intended switch to
// soft-delete would (correctly) make this test fail and prompt a review,
// rather than the old "soft delete" name masking the mismatch.
const res = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/items/${createdItemId}`,
headers: authHeader(adminToken),
});
// The current API hard-deletes the item (the route does
// `prisma.sklad_items.delete`, not an `is_active=false` toggle).
// Verify the row is gone rather than checking is_active.
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
// The row is actually gone — a subsequent GET 404s.
const checkRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${createdItemId}`,
@@ -1030,6 +1033,124 @@ describe("Issue flow", () => {
});
});
// ---------------------------------------------------------------------------
// 6b. FIFO ordering — the OLDEST batch is consumed first
// ---------------------------------------------------------------------------
describe("Issue FIFO ordering (oldest batch first)", () => {
it("consumes the batch with the earliest received_at first, not the lowest id", async () => {
// Build two batches for one item via two confirmed receipts.
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_fifo`, unit: "ks" },
});
const fifoItemId = itemRes.json().data.id as number;
const locA = (
await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "fifo loc A" },
})
).json().data.id as number;
const locB = (
await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "fifo loc B" },
})
).json().data.id as number;
// Helper: create + confirm a receipt of `qty` at `locId`.
const confirmReceipt = async (qty: number, locId: number) => {
const r = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}fifo_receipt`,
items: [
{
item_id: fifoItemId,
quantity: qty,
unit_price: 10,
location_id: locId,
},
],
},
});
const id = r.json().data.id as number;
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${id}/confirm`,
headers: authHeader(adminToken),
});
};
// batchEarly is created FIRST (lower id); batchLate is created SECOND
// (higher id). We then flip their received_at so the HIGHER-id batch is the
// OLDEST — this is what lets the test prove FIFO sorts by received_at, not
// by insertion id.
await confirmReceipt(10, locA); // → batch with the lower id
await confirmReceipt(10, locB); // → batch with the higher id
const batchesRaw = await prisma.sklad_batches.findMany({
where: { item_id: fifoItemId },
orderBy: { id: "asc" },
});
expect(batchesRaw.length).toBe(2);
const lowerId = batchesRaw[0]; // created first
const higherId = batchesRaw[1]; // created second
// Make the HIGHER-id batch the OLDEST by received_at.
await prisma.sklad_batches.update({
where: { id: lowerId.id },
data: { received_at: new Date("2021-01-01T00:00:00Z") },
});
await prisma.sklad_batches.update({
where: { id: higherId.id },
data: { received_at: new Date("2020-01-01T00:00:00Z") }, // older
});
// Issue 5 with no explicit batch → FIFO must auto-select the OLDEST batch
// (the higher-id one we back-dated), partially depleting it.
const issueRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/issues",
headers: authHeader(adminToken),
payload: {
project_id: projectId,
notes: `${N}fifo_issue`,
items: [{ item_id: fifoItemId, quantity: 5 }],
},
});
expect(issueRes.statusCode).toBe(201);
const issueId = issueRes.json().data.id as number;
const confirmRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/issues/${issueId}/confirm`,
headers: authHeader(adminToken),
});
expect(confirmRes.statusCode).toBe(200);
// The OLDEST batch (higher id, received 2020) is depleted from 10 → 5;
// the newer batch (lower id, received 2021) is untouched at 10. If FIFO
// wrongly sorted by id it would have consumed the lower-id batch instead.
const oldest = await prisma.sklad_batches.findUnique({
where: { id: higherId.id },
});
const newest = await prisma.sklad_batches.findUnique({
where: { id: lowerId.id },
});
expect(Number(oldest!.quantity)).toBe(5);
expect(Number(newest!.quantity)).toBe(10);
});
});
// ---------------------------------------------------------------------------
// 7. Reservation flow
// ---------------------------------------------------------------------------