test: add regression tests for Critical+High FLAWS_REPORT fixes
- Tests caught 2 real bugs:
- Zod NaN bypass in orders/offers schemas (Number(v) || fallback)
- invoiceTotalWithVat using Number() on { toNumber() } objects
- 7 new test files covering auth, env, exchange rates, NAS paths,
schema NaN rejection, invoice VAT calculation, customer validation
- 45 tests passing, build clean
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
49
src/__tests__/auth.service.test.ts
Normal file
49
src/__tests__/auth.service.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { verifyAccessToken, hashToken } from "../services/auth";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { config } from "../config/env";
|
||||
|
||||
describe("auth service", () => {
|
||||
describe("verifyAccessToken", () => {
|
||||
it("returns null and logs error for invalid JWT", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
const result = await verifyAccessToken("invalid-token");
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
expect(consoleSpy.mock.calls[0][0]).toMatch(/JWT verification error/);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null for expired JWT", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
const expiredToken = jwt.sign(
|
||||
{ sub: 1, username: "test", role: "user" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: -1 },
|
||||
);
|
||||
const result = await verifyAccessToken(expiredToken);
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashToken", () => {
|
||||
it("produces deterministic SHA-256 hex output", () => {
|
||||
const t1 = hashToken("hello");
|
||||
const t2 = hashToken("hello");
|
||||
expect(t1).toBe(t2);
|
||||
expect(t1).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("produces different hashes for different inputs", () => {
|
||||
const t1 = hashToken("a");
|
||||
const t2 = hashToken("b");
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
});
|
||||
19
src/__tests__/customers.schema.test.ts
Normal file
19
src/__tests__/customers.schema.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { UpdateCustomerSchema } from "../schemas/customers.schema";
|
||||
|
||||
describe("UpdateCustomerSchema", () => {
|
||||
it("rejects empty name", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ name: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid name", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ name: "Acme Corp" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts partial updates without name", () => {
|
||||
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
34
src/__tests__/env.test.ts
Normal file
34
src/__tests__/env.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { config } from "../config/env";
|
||||
|
||||
describe("env validation", () => {
|
||||
it("has numeric port within valid range", () => {
|
||||
expect(typeof config.port).toBe("number");
|
||||
expect(config.port).toBeGreaterThan(0);
|
||||
expect(config.port).toBeLessThanOrEqual(65535);
|
||||
});
|
||||
|
||||
it("has JWT_SECRET defined", () => {
|
||||
expect(config.jwt.secret).toBeTruthy();
|
||||
expect(config.jwt.secret.length).toBeGreaterThanOrEqual(32);
|
||||
});
|
||||
|
||||
it("has TOTP_ENCRYPTION_KEY defined", () => {
|
||||
expect(config.totp.encryptionKey).toBeTruthy();
|
||||
expect(config.totp.encryptionKey.length).toBeGreaterThanOrEqual(32);
|
||||
});
|
||||
|
||||
it("has positive JWT expiry values", () => {
|
||||
expect(config.jwt.accessTokenExpiry).toBeGreaterThan(0);
|
||||
expect(config.jwt.refreshTokenSessionExpiry).toBeGreaterThan(0);
|
||||
expect(config.jwt.refreshTokenRememberExpiry).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has positive maxUploadSize", () => {
|
||||
expect(config.nas.maxUploadSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has DATABASE_URL defined", () => {
|
||||
expect(config.db.url).toBeTruthy();
|
||||
});
|
||||
});
|
||||
64
src/__tests__/exchange-rates.test.ts
Normal file
64
src/__tests__/exchange-rates.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { toCzk, getRate } from "../services/exchange-rates";
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("exchange-rates", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("toCzk", () => {
|
||||
it("returns amount unchanged for CZK", async () => {
|
||||
const result = await toCzk(123.45, "CZK");
|
||||
expect(result).toBe(123.45);
|
||||
});
|
||||
|
||||
it("throws for unknown currency when API fails and no cache", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
await expect(toCzk(100, "XYZ")).rejects.toThrow(
|
||||
/Nepodařilo se získat aktuální kurzy/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for unknown currency even when API succeeds", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
||||
}),
|
||||
});
|
||||
await expect(toCzk(100, "XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
||||
});
|
||||
|
||||
it("converts EUR using fetched rate", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
||||
}),
|
||||
});
|
||||
const result = await toCzk(100, "EUR");
|
||||
expect(result).toBe(2500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRate", () => {
|
||||
it("returns 1 for CZK", async () => {
|
||||
const result = await getRate("CZK");
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("throws for unknown currency", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
|
||||
}),
|
||||
});
|
||||
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
src/__tests__/invoices.service.test.ts
Normal file
55
src/__tests__/invoices.service.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
||||
|
||||
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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(invoiceTotalWithVat(inv)).toBe(200);
|
||||
});
|
||||
|
||||
it("rounds each line VAT to 2 decimals before accumulation", () => {
|
||||
// 3 items @ 33.33 with 21% VAT
|
||||
// Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00
|
||||
// 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 },
|
||||
})),
|
||||
};
|
||||
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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(invoiceTotalWithVat(inv)).toBe(0);
|
||||
});
|
||||
});
|
||||
55
src/__tests__/nas-file-manager.test.ts
Normal file
55
src/__tests__/nas-file-manager.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NasFileManager } from "../services/nas-file-manager";
|
||||
|
||||
describe("NasFileManager path traversal", () => {
|
||||
const nas = new NasFileManager();
|
||||
|
||||
describe("deleteItem", () => {
|
||||
it("rejects empty path", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", "");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects root path /", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", "/");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects current directory .", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", ".");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects current directory ./", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", "./");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects path traversal ..", async () => {
|
||||
const result = await nas.deleteItem("PRJ-001", "../etc/passwd");
|
||||
expect(result).toContain("Neplatná cesta");
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveItem", () => {
|
||||
it("rejects empty fromPath", async () => {
|
||||
const result = await nas.moveItem("PRJ-001", "", "dest");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects root fromPath /", async () => {
|
||||
const result = await nas.moveItem("PRJ-001", "/", "dest");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects current directory .", async () => {
|
||||
const result = await nas.moveItem("PRJ-001", ".", "dest");
|
||||
expect(result).toContain("kořenovou složku");
|
||||
});
|
||||
|
||||
it("rejects path traversal in fromPath", async () => {
|
||||
const result = await nas.moveItem("PRJ-001", "../secret", "dest");
|
||||
expect(result).toContain("Neplatná cesta");
|
||||
});
|
||||
});
|
||||
});
|
||||
57
src/__tests__/schema-nan.test.ts
Normal file
57
src/__tests__/schema-nan.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CreateOrderSchema } from "../schemas/orders.schema";
|
||||
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||
|
||||
describe("Zod NaN rejection", () => {
|
||||
describe("CreateOrderSchema", () => {
|
||||
it("rejects NaN string in quantity", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: "not-a-number" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects NaN string in unit_price", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ unit_price: "abc" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid numbers", () => {
|
||||
const result = CreateOrderSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: 2, unit_price: 100 }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateQuotationSchema", () => {
|
||||
it("rejects NaN string in top-level vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: "bad",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid vat_rate", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
vat_rate: 21,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects NaN string in item quantity", () => {
|
||||
const result = CreateQuotationSchema.safeParse({
|
||||
customer_id: 1,
|
||||
items: [{ quantity: "bad" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,14 @@ const QuotationItemSchema = z.object({
|
||||
item_description: z.string().nullish(),
|
||||
quantity: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v) || 1)
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(1),
|
||||
unit: z.string().nullish(),
|
||||
unit_price: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v) || 0)
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(0),
|
||||
|
||||
@@ -5,14 +5,14 @@ const OrderItemSchema = z.object({
|
||||
item_description: z.string().nullish(),
|
||||
quantity: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v) || 1)
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(1),
|
||||
unit: z.string().nullish(),
|
||||
unit_price: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => Number(v) || 0)
|
||||
.transform((v) => Number(v))
|
||||
.refine((v) => !Number.isNaN(v), { message: "Must be a valid number" })
|
||||
.optional()
|
||||
.default(0),
|
||||
|
||||
@@ -155,7 +155,7 @@ export {
|
||||
previewInvoiceNumber as getNextInvoiceNumberPreview,
|
||||
} from "./numbering.service";
|
||||
|
||||
function invoiceTotalWithVat(inv: {
|
||||
export function invoiceTotalWithVat(inv: {
|
||||
apply_vat: boolean | null;
|
||||
vat_rate: { toNumber(): number } | null;
|
||||
currency: string | null;
|
||||
@@ -166,14 +166,23 @@ function invoiceTotalWithVat(inv: {
|
||||
}>;
|
||||
}) {
|
||||
const sub = inv.invoice_items.reduce(
|
||||
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
(s, i) =>
|
||||
s +
|
||||
(Number(i.quantity?.toNumber()) || 0) *
|
||||
(Number(i.unit_price?.toNumber()) || 0),
|
||||
0,
|
||||
);
|
||||
const vat = inv.apply_vat
|
||||
? inv.invoice_items.reduce((s, i) => {
|
||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||
const base =
|
||||
(Number(i.quantity?.toNumber()) || 0) *
|
||||
(Number(i.unit_price?.toNumber()) || 0);
|
||||
const lineVat =
|
||||
base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100);
|
||||
base *
|
||||
((Number(i.vat_rate?.toNumber()) ||
|
||||
Number(inv.vat_rate?.toNumber()) ||
|
||||
21) /
|
||||
100);
|
||||
return s + Math.round(lineVat * 100) / 100;
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
Reference in New Issue
Block a user