diff --git a/src/__tests__/auth.service.test.ts b/src/__tests__/auth.service.test.ts new file mode 100644 index 0000000..578cae4 --- /dev/null +++ b/src/__tests__/auth.service.test.ts @@ -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); + }); + }); +}); diff --git a/src/__tests__/customers.schema.test.ts b/src/__tests__/customers.schema.test.ts new file mode 100644 index 0000000..3ac8261 --- /dev/null +++ b/src/__tests__/customers.schema.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/env.test.ts b/src/__tests__/env.test.ts new file mode 100644 index 0000000..83b4b85 --- /dev/null +++ b/src/__tests__/env.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/exchange-rates.test.ts b/src/__tests__/exchange-rates.test.ts new file mode 100644 index 0000000..4ea603a --- /dev/null +++ b/src/__tests__/exchange-rates.test.ts @@ -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/); + }); + }); +}); diff --git a/src/__tests__/invoices.service.test.ts b/src/__tests__/invoices.service.test.ts new file mode 100644 index 0000000..58931d0 --- /dev/null +++ b/src/__tests__/invoices.service.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/nas-file-manager.test.ts b/src/__tests__/nas-file-manager.test.ts new file mode 100644 index 0000000..172030e --- /dev/null +++ b/src/__tests__/nas-file-manager.test.ts @@ -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"); + }); + }); +}); diff --git a/src/__tests__/schema-nan.test.ts b/src/__tests__/schema-nan.test.ts new file mode 100644 index 0000000..bf496f3 --- /dev/null +++ b/src/__tests__/schema-nan.test.ts @@ -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); + }); + }); +}); diff --git a/src/schemas/offers.schema.ts b/src/schemas/offers.schema.ts index 79e5e93..d511bf2 100644 --- a/src/schemas/offers.schema.ts +++ b/src/schemas/offers.schema.ts @@ -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), diff --git a/src/schemas/orders.schema.ts b/src/schemas/orders.schema.ts index 208e00a..976e956 100644 --- a/src/schemas/orders.schema.ts +++ b/src/schemas/orders.schema.ts @@ -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), diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index b2824f7..48bfd16 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -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;