From e5ba3e1ed91e48a19f75c1101f6691f6d60d2cc3 Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:53:36 +0100 Subject: [PATCH] refractor code to allow more tests --- app/(dashboard)/proxy-hosts/actions.ts | 106 +---- src/lib/caddy-utils.ts | 203 ++++++++++ src/lib/caddy.ts | 240 +---------- src/lib/form-parse.ts | 61 +++ tests/docker-compose.test.yml | 14 + tests/e2e/functional/access-control.spec.ts | 68 ++++ tests/e2e/functional/load-balancing.spec.ts | 60 +++ tests/e2e/functional/proxy-routing.spec.ts | 66 +++ tests/e2e/functional/ssl-redirect.spec.ts | 64 +++ tests/e2e/functional/waf-blocking.spec.ts | 68 ++++ tests/helpers/http.ts | 79 ++++ tests/helpers/proxy-api.ts | 98 +++++ .../access-lists-passwords.test.ts | 119 ++++++ tests/integration/proxy-hosts-meta.test.ts | 241 +++++++++++ tests/playwright.config.ts | 2 +- tests/unit/caddy-utils.test.ts | 383 ++++++++++++++++++ tests/unit/form-parse.test.ts | 284 +++++++++++++ 17 files changed, 1833 insertions(+), 323 deletions(-) create mode 100644 src/lib/caddy-utils.ts create mode 100644 src/lib/form-parse.ts create mode 100644 tests/e2e/functional/access-control.spec.ts create mode 100644 tests/e2e/functional/load-balancing.spec.ts create mode 100644 tests/e2e/functional/proxy-routing.spec.ts create mode 100644 tests/e2e/functional/ssl-redirect.spec.ts create mode 100644 tests/e2e/functional/waf-blocking.spec.ts create mode 100644 tests/helpers/http.ts create mode 100644 tests/helpers/proxy-api.ts create mode 100644 tests/integration/access-lists-passwords.test.ts create mode 100644 tests/integration/proxy-hosts-meta.test.ts create mode 100644 tests/unit/caddy-utils.test.ts create mode 100644 tests/unit/form-parse.test.ts diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 0e54ed4d..1dbbb19e 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -18,88 +18,15 @@ import { } from "@/src/lib/models/proxy-hosts"; import { getCertificate } from "@/src/lib/models/certificates"; import { getCloudflareSettings, type GeoBlockSettings } from "@/src/lib/settings"; - -function parseCsv(value: FormDataEntryValue | null): string[] { - if (!value || typeof value !== "string") { - return []; - } - return value - .replace(/\n/g, ",") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -// Parse upstreams by newline only (URLs may contain commas in query strings) -function parseUpstreams(value: FormDataEntryValue | null): string[] { - if (!value || typeof value !== "string") { - return []; - } - return value - .split("\n") - .map((item) => item.trim()) - .filter(Boolean); -} - -function parseCheckbox(value: FormDataEntryValue | null): boolean { - return value === "on" || value === "true" || value === "1"; -} - -function parseOptionalText(value: FormDataEntryValue | null): string | null { - if (!value || typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function parseCertificateId(value: FormDataEntryValue | null): number | null { - if (!value || value === "") { - return null; - } - - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - if (trimmed === "" || trimmed === "null" || trimmed === "undefined") { - return null; - } - - const num = Number(trimmed); - - // Check for NaN, Infinity, or non-integer values - if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) { - return null; - } - - return num; -} - -function parseAccessListId(value: FormDataEntryValue | null): number | null { - if (!value || value === "") { - return null; - } - - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - if (trimmed === "" || trimmed === "null" || trimmed === "undefined") { - return null; - } - - const num = Number(trimmed); - - // Check for NaN, Infinity, or non-integer values - if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) { - return null; - } - - return num; -} +import { + parseCsv, + parseUpstreams, + parseCheckbox, + parseOptionalText, + parseCertificateId, + parseAccessListId, + parseOptionalNumber, +} from "@/src/lib/form-parse"; async function validateAndSanitizeCertificateId( certificateId: number | null, @@ -192,20 +119,6 @@ function parseRedirectUrl(raw: FormDataEntryValue | null): string { } } -function parseOptionalNumber(value: FormDataEntryValue | null): number | null { - if (!value || typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (trimmed === "") { - return null; - } - const num = Number(trimmed); - if (!Number.isFinite(num)) { - return null; - } - return num; -} const VALID_LB_POLICIES: LoadBalancingPolicy[] = ["random", "round_robin", "least_conn", "ip_hash", "first", "header", "cookie", "uri_hash"]; const VALID_UPSTREAM_DNS_FAMILIES = ["ipv6", "ipv4", "both"] as const; @@ -539,6 +452,7 @@ export async function createProxyHostAction( upstreams: parseUpstreams(formData.get("upstreams")), certificate_id: certificateId, access_list_id: parseAccessListId(formData.get("access_list_id")), + ssl_forced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined, hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")), skip_https_hostname_validation: parseCheckbox(formData.get("skip_https_hostname_validation")), enabled: parseCheckbox(formData.get("enabled")), diff --git a/src/lib/caddy-utils.ts b/src/lib/caddy-utils.ts new file mode 100644 index 00000000..46487ae0 --- /dev/null +++ b/src/lib/caddy-utils.ts @@ -0,0 +1,203 @@ +/** + * Pure utility functions extracted from caddy.ts. + * No DB, network, or filesystem dependencies — safe to unit-test directly. + */ +import { isIP } from "node:net"; + +// --------------------------------------------------------------------------- +// Private range expansion +// --------------------------------------------------------------------------- + +export const PRIVATE_RANGES_CIDRS = [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "fd00::/8", + "::1/128", +]; + +export function expandPrivateRanges(proxies: string[]): string[] { + if (!proxies.includes("private_ranges")) return proxies; + return proxies.flatMap((p) => (p === "private_ranges" ? PRIVATE_RANGES_CIDRS : [p])); +} + +// --------------------------------------------------------------------------- +// Type helpers +// --------------------------------------------------------------------------- + +export function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +// --------------------------------------------------------------------------- +// Deep merge (prototype-pollution safe) +// --------------------------------------------------------------------------- + +export function mergeDeep( + target: Record, + source: Record +) { + for (const [key, value] of Object.entries(source)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + const existing = target[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + mergeDeep(existing, value); + } else { + target[key] = value; + } + } +} + +// --------------------------------------------------------------------------- +// JSON helpers +// --------------------------------------------------------------------------- + +export function parseJson(value: string | null, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(value) as T; + } catch (error) { + console.warn("Failed to parse JSON value", value, error); + return fallback; + } +} + +export function parseOptionalJson(value: string | null | undefined) { + if (!value) return null; + try { + return JSON.parse(value); + } catch (error) { + console.warn("Failed to parse custom JSON", error); + return null; + } +} + +export function parseCustomHandlers( + value: string | null | undefined +): Record[] { + const parsed = parseOptionalJson(value); + if (!parsed) return []; + const list = Array.isArray(parsed) ? parsed : [parsed]; + const handlers: Record[] = []; + for (const item of list) { + if (isPlainObject(item)) { + handlers.push(item); + } else { + console.warn("Ignoring custom handler entry that is not an object", item); + } + } + return handlers; +} + +// --------------------------------------------------------------------------- +// Address / upstream parsing +// --------------------------------------------------------------------------- + +export function formatDialAddress(host: string, port: string) { + return isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`; +} + +export function parseHostPort( + value: string +): { host: string; port: string } | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith("[")) { + const closeIndex = trimmed.indexOf("]"); + if (closeIndex <= 1) return null; + const host = trimmed.slice(1, closeIndex); + const remainder = trimmed.slice(closeIndex + 1); + if (!remainder.startsWith(":")) return null; + const port = remainder.slice(1).trim(); + if (!port) return null; + return { host, port }; + } + + const firstColon = trimmed.indexOf(":"); + const lastColon = trimmed.lastIndexOf(":"); + if (firstColon === -1 || firstColon !== lastColon) return null; + + const host = trimmed.slice(0, lastColon).trim(); + const port = trimmed.slice(lastColon + 1).trim(); + if (!host || !port) return null; + + return { host, port }; +} + +export type ParsedUpstreamTarget = { + original: string; + dial: string; + scheme: "http" | "https" | null; + host: string | null; + port: string | null; +}; + +export function parseUpstreamTarget(upstream: string): ParsedUpstreamTarget { + const trimmed = upstream.trim(); + if (!trimmed) { + return { original: upstream, dial: upstream, scheme: null, host: null, port: null }; + } + + try { + const url = new URL(trimmed); + if (url.protocol === "http:" || url.protocol === "https:") { + const scheme = url.protocol === "https:" ? "https" : "http"; + const port = url.port || (scheme === "https" ? "443" : "80"); + const host = url.hostname; + return { original: trimmed, dial: formatDialAddress(host, port), scheme, host, port }; + } + } catch { + // fall through + } + + const parsed = parseHostPort(trimmed); + if (!parsed) { + return { original: trimmed, dial: trimmed, scheme: null, host: null, port: null }; + } + + return { + original: trimmed, + dial: formatDialAddress(parsed.host, parsed.port), + scheme: null, + host: parsed.host, + port: parsed.port, + }; +} + +// --------------------------------------------------------------------------- +// Duration parsing +// --------------------------------------------------------------------------- + +export function toDurationMs(value: string | null | undefined): number | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + const regex = /(\d+(?:\.\d+)?)(ms|s|m|h)/g; + let total = 0; + let matched = false; + let consumed = 0; + + while (true) { + const match = regex.exec(trimmed); + if (!match) break; + matched = true; + consumed += match[0].length; + const valueNum = Number.parseFloat(match[1]); + if (!Number.isFinite(valueNum)) return null; + const unit = match[2]; + if (unit === "ms") total += valueNum; + else if (unit === "s") total += valueNum * 1000; + else if (unit === "m") total += valueNum * 60_000; + else if (unit === "h") total += valueNum * 3_600_000; + } + + if (!matched || consumed !== trimmed.length) return null; + + const rounded = Math.round(total); + return rounded > 0 ? rounded : null; +} diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 311c3b5a..bb27147b 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -3,6 +3,20 @@ import { Resolver } from "node:dns/promises"; import { join } from "node:path"; import { isIP } from "node:net"; import crypto from "node:crypto"; +import { + PRIVATE_RANGES_CIDRS, + expandPrivateRanges, + isPlainObject, + mergeDeep, + parseJson, + parseOptionalJson, + parseCustomHandlers, + formatDialAddress, + parseHostPort, + parseUpstreamTarget, + toDurationMs, + type ParsedUpstreamTarget, +} from "./caddy-utils"; import http from "node:http"; import https from "node:https"; import db, { nowIso } from "./db"; @@ -54,21 +68,6 @@ const DEFAULT_AUTHENTIK_HEADERS = [ const DEFAULT_AUTHENTIK_TRUSTED_PROXIES = ["private_ranges"]; -// The caddy-blocker-plugin accepts only literal IP/CIDR strings, not Caddy's -// "private_ranges" shorthand. Expand it before building the blocker config. -const PRIVATE_RANGES_CIDRS = [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "fd00::/8", - "::1/128" -]; - -function expandPrivateRanges(proxies: string[]): string[] { - if (!proxies.includes("private_ranges")) return proxies; - return proxies.flatMap((p) => (p === "private_ranges" ? PRIVATE_RANGES_CIDRS : [p])); -} type ProxyHostRow = { id: number; @@ -215,80 +214,8 @@ type CertificateUsage = { domains: Set; }; -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function parseJson(value: string | null, fallback: T): T { - if (!value) { - return fallback; - } - try { - return JSON.parse(value) as T; - } catch (error) { - console.warn("Failed to parse JSON value", value, error); - return fallback; - } -} - -function parseOptionalJson(value: string | null | undefined) { - if (!value) { - return null; - } - try { - return JSON.parse(value); - } catch (error) { - console.warn("Failed to parse custom JSON", error); - return null; - } -} - -function mergeDeep(target: Record, source: Record) { - for (const [key, value] of Object.entries(source)) { - // Block prototype-polluting keys - if ( - key === "__proto__" || - key === "constructor" || - key === "prototype" - ) { - continue; - } - const existing = target[key]; - if (isPlainObject(existing) && isPlainObject(value)) { - mergeDeep(existing, value); - } else { - target[key] = value; - } - } -} - -function parseCustomHandlers(value: string | null | undefined): Record[] { - const parsed = parseOptionalJson(value); - if (!parsed) { - return []; - } - const list = Array.isArray(parsed) ? parsed : [parsed]; - const handlers: Record[] = []; - for (const item of list) { - if (isPlainObject(item)) { - handlers.push(item); - } else { - console.warn("Ignoring custom handler entry that is not an object", item); - } - } - return handlers; -} - const VALID_UPSTREAM_DNS_FAMILIES: UpstreamDnsAddressFamily[] = ["ipv6", "ipv4", "both"]; -type ParsedUpstreamTarget = { - original: string; - dial: string; - scheme: "http" | "https" | null; - host: string | null; - port: string | null; -}; - type UpstreamDnsResolutionRouteConfig = { enabled: boolean | null; family: UpstreamDnsAddressFamily | null; @@ -299,145 +226,6 @@ type EffectiveUpstreamDnsResolution = { family: UpstreamDnsAddressFamily; }; -function formatDialAddress(host: string, port: string) { - return isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`; -} - -function parseHostPort(value: string): { host: string; port: string } | null { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - if (trimmed.startsWith("[")) { - const closeIndex = trimmed.indexOf("]"); - if (closeIndex <= 1) { - return null; - } - const host = trimmed.slice(1, closeIndex); - const remainder = trimmed.slice(closeIndex + 1); - if (!remainder.startsWith(":")) { - return null; - } - const port = remainder.slice(1).trim(); - if (!port) { - return null; - } - return { host, port }; - } - - const firstColon = trimmed.indexOf(":"); - const lastColon = trimmed.lastIndexOf(":"); - if (firstColon === -1 || firstColon !== lastColon) { - return null; - } - - const host = trimmed.slice(0, lastColon).trim(); - const port = trimmed.slice(lastColon + 1).trim(); - if (!host || !port) { - return null; - } - - return { host, port }; -} - -function parseUpstreamTarget(upstream: string): ParsedUpstreamTarget { - const trimmed = upstream.trim(); - if (!trimmed) { - return { - original: upstream, - dial: upstream, - scheme: null, - host: null, - port: null - }; - } - - try { - const url = new URL(trimmed); - if (url.protocol === "http:" || url.protocol === "https:") { - const scheme = url.protocol === "https:" ? "https" : "http"; - const port = url.port || (scheme === "https" ? "443" : "80"); - const host = url.hostname; - return { - original: trimmed, - dial: formatDialAddress(host, port), - scheme, - host, - port - }; - } - } catch { - // Ignore and parse as host:port below. - } - - const parsed = parseHostPort(trimmed); - if (!parsed) { - return { - original: trimmed, - dial: trimmed, - scheme: null, - host: null, - port: null - }; - } - - return { - original: trimmed, - dial: formatDialAddress(parsed.host, parsed.port), - scheme: null, - host: parsed.host, - port: parsed.port - }; -} - -function toDurationMs(value: string | null | undefined): number | null { - if (!value) { - return null; - } - - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - const regex = /(\d+(?:\.\d+)?)(ms|s|m|h)/g; - let total = 0; - let matched = false; - let consumed = 0; - - while (true) { - const match = regex.exec(trimmed); - if (!match) { - break; - } - - matched = true; - consumed += match[0].length; - const valueNum = Number.parseFloat(match[1]); - if (!Number.isFinite(valueNum)) { - return null; - } - const unit = match[2]; - if (unit === "ms") { - total += valueNum; - } else if (unit === "s") { - total += valueNum * 1000; - } else if (unit === "m") { - total += valueNum * 60_000; - } else if (unit === "h") { - total += valueNum * 3_600_000; - } - } - - if (!matched || consumed !== trimmed.length) { - return null; - } - - const rounded = Math.round(total); - return rounded > 0 ? rounded : null; -} - function parseUpstreamDnsResolutionConfig( meta: UpstreamDnsResolutionMeta | undefined | null ): UpstreamDnsResolutionRouteConfig | null { diff --git a/src/lib/form-parse.ts b/src/lib/form-parse.ts new file mode 100644 index 00000000..794f6d5a --- /dev/null +++ b/src/lib/form-parse.ts @@ -0,0 +1,61 @@ +/** + * Pure FormData parsing utilities extracted from proxy-hosts/actions.ts. + * No DB or network dependencies — safe to unit-test directly. + */ + +export function parseCsv(value: FormDataEntryValue | null): string[] { + if (!value || typeof value !== "string") return []; + return value + .replace(/\n/g, ",") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +/** Parse upstreams by newline only (URLs may contain commas in query strings). */ +export function parseUpstreams(value: FormDataEntryValue | null): string[] { + if (!value || typeof value !== "string") return []; + return value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); +} + +export function parseCheckbox(value: FormDataEntryValue | null): boolean { + return value === "on" || value === "true" || value === "1"; +} + +export function parseOptionalText(value: FormDataEntryValue | null): string | null { + if (!value || typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function parseCertificateId(value: FormDataEntryValue | null): number | null { + if (!value || value === "") return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed === "" || trimmed === "null" || trimmed === "undefined") return null; + const num = Number(trimmed); + if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) return null; + return num; +} + +export function parseAccessListId(value: FormDataEntryValue | null): number | null { + if (!value || value === "") return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed === "" || trimmed === "null" || trimmed === "undefined") return null; + const num = Number(trimmed); + if (!Number.isFinite(num) || !Number.isInteger(num) || num <= 0) return null; + return num; +} + +export function parseOptionalNumber(value: FormDataEntryValue | null): number | null { + if (!value || typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed === "") return null; + const num = Number(trimmed); + if (!Number.isFinite(num)) return null; + return num; +} diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml index a155dc9d..815872b1 100644 --- a/tests/docker-compose.test.yml +++ b/tests/docker-compose.test.yml @@ -10,6 +10,20 @@ services: ports: - "80:80" - "443:443" + # Lightweight echo server reachable by Caddy as "echo-server:8080". + # Returns a fixed body so tests can assert the proxy routed the request. + echo-server: + image: hashicorp/http-echo + command: ["-text=echo-ok", "-listen=:8080"] + networks: + - caddy-network + # Second echo server for load-balancing tests. + # Returns a different body so tests can distinguish which upstream served the request. + echo-server-2: + image: hashicorp/http-echo + command: ["-text=echo-server-2", "-listen=:8080"] + networks: + - caddy-network volumes: caddy-manager-data: name: caddy-manager-data-test diff --git a/tests/e2e/functional/access-control.spec.ts b/tests/e2e/functional/access-control.spec.ts new file mode 100644 index 00000000..63e53685 --- /dev/null +++ b/tests/e2e/functional/access-control.spec.ts @@ -0,0 +1,68 @@ +/** + * Functional tests: HTTP Basic Auth via access lists. + * + * Creates an access list with a test user, attaches it to a proxy host, + * and verifies Caddy enforces authentication before forwarding requests + * to the upstream echo server. + * + * Domain: func-auth.test + */ +import { test, expect } from '@playwright/test'; +import { createProxyHost, createAccessList } from '../../helpers/proxy-api'; +import { httpGet, waitForRoute } from '../../helpers/http'; + +const DOMAIN = 'func-auth.test'; +const LIST_NAME = 'Functional Auth List'; +const TEST_USER = { username: 'testuser', password: 'S3cur3P@ss!' }; +const ECHO_BODY = 'echo-ok'; + +function basicAuth(username: string, password: string): string { + return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); +} + +test.describe.serial('Access Control (HTTP Basic Auth)', () => { + test('setup: create access list and attach to proxy host', async ({ page }) => { + await createAccessList(page, LIST_NAME, [TEST_USER]); + await createProxyHost(page, { + name: 'Functional Auth Test', + domain: DOMAIN, + upstream: 'echo-server:8080', + accessListName: LIST_NAME, + }); + await waitForRoute(DOMAIN); + }); + + test('request without credentials returns 401', async () => { + const res = await httpGet(DOMAIN); + expect(res.status).toBe(401); + }); + + test('request with wrong password returns 401', async () => { + const res = await httpGet(DOMAIN, '/', { + Authorization: basicAuth(TEST_USER.username, 'wrongpassword'), + }); + expect(res.status).toBe(401); + }); + + test('request with wrong username returns 401', async () => { + const res = await httpGet(DOMAIN, '/', { + Authorization: basicAuth('wronguser', TEST_USER.password), + }); + expect(res.status).toBe(401); + }); + + test('request with correct credentials reaches upstream', async () => { + const res = await httpGet(DOMAIN, '/', { + Authorization: basicAuth(TEST_USER.username, TEST_USER.password), + }); + expect(res.status).toBe(200); + expect(res.body).toContain(ECHO_BODY); + }); + + test('401 response includes WWW-Authenticate header', async () => { + const res = await httpGet(DOMAIN); + expect(res.status).toBe(401); + const wwwAuth = res.headers['www-authenticate']; + expect(String(Array.isArray(wwwAuth) ? wwwAuth[0] : wwwAuth)).toMatch(/basic/i); + }); +}); diff --git a/tests/e2e/functional/load-balancing.spec.ts b/tests/e2e/functional/load-balancing.spec.ts new file mode 100644 index 00000000..22235a95 --- /dev/null +++ b/tests/e2e/functional/load-balancing.spec.ts @@ -0,0 +1,60 @@ +/** + * Functional tests: round-robin load balancing across multiple upstreams. + * + * Creates a proxy host with two echo servers as upstreams. Each server + * returns a distinct body so tests can verify that traffic is distributed + * across both backends. + * + * Domain: func-lb.test + */ +import { test, expect } from '@playwright/test'; +import { createProxyHost } from '../../helpers/proxy-api'; +import { httpGet, waitForRoute } from '../../helpers/http'; + +const DOMAIN = 'func-lb.test'; + +test.describe.serial('Load Balancing (multiple upstreams)', () => { + test('setup: create proxy host with two upstreams', async ({ page }) => { + await createProxyHost(page, { + name: 'Functional LB Test', + domain: DOMAIN, + // Two upstreams separated by newline — both will be round-robined by Caddy. + // echo-server returns "echo-ok", echo-server-2 returns "echo-server-2". + upstream: 'echo-server:8080\necho-server-2:8080', + }); + await waitForRoute(DOMAIN); + }); + + test('all requests return 200', async () => { + for (let i = 0; i < 5; i++) { + const res = await httpGet(DOMAIN, '/'); + expect(res.status).toBe(200); + } + }); + + test('both upstreams are reached over multiple requests', async () => { + const bodies = new Set(); + + // Send enough requests that both backends should be hit via round-robin. + for (let i = 0; i < 20; i++) { + const res = await httpGet(DOMAIN, '/'); + if (res.body.includes('echo-ok') || res.body.includes('echo-server-2')) { + bodies.add(res.body.trim()); + } + } + + // Both distinct responses must appear + expect(bodies.size).toBeGreaterThanOrEqual(2); + const arr = Array.from(bodies); + expect(arr.some((b) => b.includes('echo-ok'))).toBe(true); + expect(arr.some((b) => b.includes('echo-server-2'))).toBe(true); + }); + + test('different paths all return 200', async () => { + const paths = ['/', '/api/test', '/some/deep/path', '/health']; + for (const path of paths) { + const res = await httpGet(DOMAIN, path); + expect(res.status).toBe(200); + } + }); +}); diff --git a/tests/e2e/functional/proxy-routing.spec.ts b/tests/e2e/functional/proxy-routing.spec.ts new file mode 100644 index 00000000..1521220a --- /dev/null +++ b/tests/e2e/functional/proxy-routing.spec.ts @@ -0,0 +1,66 @@ +/** + * Functional tests: basic reverse-proxy routing. + * + * Creates a real proxy host pointing at the echo-server container, + * then sends HTTP requests directly to Caddy and asserts the response + * comes from the upstream. + * + * Domain: func-proxy.test (no DNS resolution needed — requests go to + * 127.0.0.1:80 with a custom Host header, which Caddy routes by hostname). + */ +import { test, expect } from '@playwright/test'; +import { createProxyHost } from '../../helpers/proxy-api'; +import { httpGet, waitForRoute } from '../../helpers/http'; + +const DOMAIN = 'func-proxy.test'; +const ECHO_BODY = 'echo-ok'; + +test.describe.serial('Proxy Routing', () => { + test('setup: create proxy host pointing at echo server', async ({ page }) => { + await createProxyHost(page, { + name: 'Functional Proxy Test', + domain: DOMAIN, + upstream: 'echo-server:8080', + }); + await waitForRoute(DOMAIN); + }); + + test('routes HTTP requests to the upstream echo server', async () => { + const res = await httpGet(DOMAIN); + expect(res.status).toBe(200); + expect(res.body).toContain(ECHO_BODY); + }); + + test('proxies arbitrary paths to the upstream', async () => { + const res = await httpGet(DOMAIN, '/some/path?q=hello'); + expect(res.status).toBe(200); + expect(res.body).toContain(ECHO_BODY); + }); + + test('unknown domain is not proxied to the echo server', async () => { + // Caddy may return 404 or redirect (308 HTTP→HTTPS) for unmatched routes — + // either way the request must not reach the echo upstream. + const res = await httpGet('no-such-route.test'); + expect(res.status).not.toBe(200); + expect(res.body).not.toContain(ECHO_BODY); + }); + + test('disabled proxy host stops routing traffic', async ({ page }) => { + await page.goto('/proxy-hosts'); + const row = page.locator('tr', { hasText: 'Functional Proxy Test' }); + // Toggle the enabled switch (first checkbox inside the row) + await row.locator('input[type="checkbox"]').first().click({ force: true }); + // Give Caddy time to reload config + await page.waitForTimeout(3_000); + + const res = await httpGet(DOMAIN); + // Disabled host is removed from the route; Caddy may return 404 or + // redirect (308 HTTP→HTTPS) — either way the echo server is not reached. + expect(res.status).not.toBe(200); + expect(res.body).not.toContain(ECHO_BODY); + + // Re-enable + await row.locator('input[type="checkbox"]').first().click({ force: true }); + await page.waitForTimeout(2_000); + }); +}); diff --git a/tests/e2e/functional/ssl-redirect.spec.ts b/tests/e2e/functional/ssl-redirect.spec.ts new file mode 100644 index 00000000..323f2b19 --- /dev/null +++ b/tests/e2e/functional/ssl-redirect.spec.ts @@ -0,0 +1,64 @@ +/** + * Functional tests: HTTP→HTTPS redirect when ssl_forced is enabled. + * + * Creates a proxy host with ssl_forced=true (the default when the form + * field is present without the ssl_forced_present bypass) and verifies + * that plain HTTP requests receive a 308 permanent redirect to HTTPS. + * + * Domain: func-ssl.test + */ +import { test, expect } from '@playwright/test'; +import { createProxyHost } from '../../helpers/proxy-api'; +import { httpGet, waitForRoute } from '../../helpers/http'; +import { injectFormFields } from '../../helpers/http'; + +const DOMAIN = 'func-ssl.test'; + +test.describe.serial('SSL Redirect (ssl_forced)', () => { + test('setup: create proxy host with ssl_forced=true', async ({ page }) => { + // Navigate to proxy-hosts and open the create dialog manually so we can + // inject ssl_forced=true without the ssl_forced_present bypass. + await page.goto('/proxy-hosts'); + await page.getByRole('button', { name: /create host/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByLabel('Name').fill('Functional SSL Redirect Test'); + await page.getByLabel(/domains/i).fill(DOMAIN); + await page.getByPlaceholder('10.0.0.5:8080').fill('echo-server:8080'); + + // Inject ssl_forced=true (default form behavior — no override) + await injectFormFields(page, { + ssl_forced_present: 'on', + ssl_forced: 'on', // checkbox checked → ssl_forced = true + }); + + await page.getByRole('button', { name: /^create$/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('Functional SSL Redirect Test')).toBeVisible({ timeout: 10_000 }); + + await waitForRoute(DOMAIN); + }); + + test('HTTP request receives 308 redirect to HTTPS', async () => { + const res = await httpGet(DOMAIN, '/'); + // Caddy redirects HTTP→HTTPS when ssl_forced=true + expect(res.status).toBe(308); + }); + + test('redirect Location header points to HTTPS', async () => { + const res = await httpGet(DOMAIN, '/'); + expect(res.status).toBe(308); + const location = res.headers['location']; + const locationStr = Array.isArray(location) ? location[0] : (location ?? ''); + expect(locationStr).toMatch(/^https:\/\//); + expect(locationStr).toContain(DOMAIN); + }); + + test('redirect preserves the request path', async () => { + const res = await httpGet(DOMAIN, '/some/path'); + expect(res.status).toBe(308); + const location = res.headers['location']; + const locationStr = Array.isArray(location) ? location[0] : (location ?? ''); + expect(locationStr).toContain('/some/path'); + }); +}); diff --git a/tests/e2e/functional/waf-blocking.spec.ts b/tests/e2e/functional/waf-blocking.spec.ts new file mode 100644 index 00000000..2a7471ac --- /dev/null +++ b/tests/e2e/functional/waf-blocking.spec.ts @@ -0,0 +1,68 @@ +/** + * Functional tests: WAF (Web Application Firewall) blocking. + * + * Creates a proxy host with per-host WAF enabled (OWASP CRS, blocking mode) + * and verifies Caddy/Coraza blocks known attack payloads while passing + * legitimate traffic through to the echo server. + * + * Domain: func-waf.test + */ +import { test, expect } from '@playwright/test'; +import { createProxyHost } from '../../helpers/proxy-api'; +import { httpGet, waitForRoute } from '../../helpers/http'; + +const DOMAIN = 'func-waf.test'; +const ECHO_BODY = 'echo-ok'; + +test.describe.serial('WAF Blocking', () => { + test('setup: create proxy host with WAF + OWASP CRS enabled', async ({ page }) => { + await createProxyHost(page, { + name: 'Functional WAF Test', + domain: DOMAIN, + upstream: 'echo-server:8080', + enableWaf: true, + }); + await waitForRoute(DOMAIN); + }); + + test('legitimate request passes through WAF', async () => { + const res = await httpGet(DOMAIN, '/'); + expect(res.status).toBe(200); + expect(res.body).toContain(ECHO_BODY); + }); + + test('legitimate query string passes through WAF', async () => { + const res = await httpGet(DOMAIN, '/search?q=hello+world&page=2'); + expect(res.status).toBe(200); + expect(res.body).toContain(ECHO_BODY); + }); + + test('SQL injection UNION SELECT is blocked (CRS rule 942xxx)', async () => { + // URL-encoded: ?id=1' UNION SELECT 1,2,3-- + const res = await httpGet(DOMAIN, "/search?id=1'%20UNION%20SELECT%201%2C2%2C3--"); + expect(res.status).toBe(403); + }); + + test("SQL injection OR '1'='1 is blocked", async () => { + // URL-encoded: ?id=1' OR '1'='1 + const res = await httpGet(DOMAIN, "/item?id=1'%20OR%20'1'%3D'1"); + expect(res.status).toBe(403); + }); + + test('XSS + const res = await httpGet(DOMAIN, '/page?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E'); + expect(res.status).toBe(403); + }); + + test('XSS javascript: URI is blocked', async () => { + // URL-encoded: ?url=javascript:alert(document.cookie) + const res = await httpGet(DOMAIN, '/redir?url=javascript%3Aalert(document.cookie)'); + expect(res.status).toBe(403); + }); + + test('path traversal ../../etc/passwd is blocked (CRS rule 930xxx)', async () => { + const res = await httpGet(DOMAIN, '/files/..%2F..%2F..%2Fetc%2Fpasswd'); + expect(res.status).toBe(403); + }); +}); diff --git a/tests/helpers/http.ts b/tests/helpers/http.ts new file mode 100644 index 00000000..3db54214 --- /dev/null +++ b/tests/helpers/http.ts @@ -0,0 +1,79 @@ +/** + * Low-level HTTP helper for functional tests. + * + * Sends requests directly to Caddy on port 80 using a custom Host header, + * bypassing DNS so test domains don't need to be resolvable. + */ +import http from 'node:http'; +import type { Page } from '@playwright/test'; + +export interface HttpResponse { + status: number; + headers: Record; + body: string; +} + +/** Make an HTTP request to Caddy (localhost:80) with a custom Host header. */ +export function httpGet(domain: string, path = '/', extraHeaders: Record = {}): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port: 80, + path, + method: 'GET', + headers: { Host: domain, ...extraHeaders }, + }, + (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('end', () => + resolve({ status: res.statusCode!, headers: res.headers as HttpResponse['headers'], body }) + ); + } + ); + req.on('error', reject); + req.end(); + }); +} + +/** + * Poll until the route responds with a status other than 502/503/504 + * (which Caddy returns while the config reload is in-flight or the + * upstream hasn't been wired up yet). + */ +export async function waitForRoute(domain: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastStatus = 0; + while (Date.now() < deadline) { + try { + const res = await httpGet(domain); + lastStatus = res.status; + if (res.status !== 502 && res.status !== 503 && res.status !== 504) return; + } catch { + // Connection refused — Caddy not ready yet + } + await new Promise(r => setTimeout(r, 500)); + } + throw new Error(`Route for "${domain}" not ready after ${timeoutMs}ms (last status: ${lastStatus})`); +} + +/** Inject hidden form fields into #create-host-form before submitting. */ +export async function injectFormFields(page: Page, fields: Record): Promise { + await page.evaluate((f) => { + const form = document.getElementById('create-host-form'); + if (!form) throw new Error('create-host-form not found'); + for (const [name, value] of Object.entries(f)) { + const existing = form.querySelector(`input[name="${name}"]`); + if (existing) { + existing.value = value; + } else { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.appendChild(input); + } + } + }, fields); +} diff --git a/tests/helpers/proxy-api.ts b/tests/helpers/proxy-api.ts new file mode 100644 index 00000000..092643c5 --- /dev/null +++ b/tests/helpers/proxy-api.ts @@ -0,0 +1,98 @@ +/** + * Higher-level helpers for creating proxy hosts and access lists + * in functional E2E tests. + * + * All helpers accept a Playwright `Page` (pre-authenticated via the + * global storageState) so they integrate cleanly with the standard + * `page` test fixture. + */ +import { expect, type Page } from '@playwright/test'; +import { injectFormFields } from './http'; + +export interface ProxyHostConfig { + name: string; + domain: string; + upstream: string; // e.g. "echo-server:8080" + accessListName?: string; // name of an existing access list to attach + enableWaf?: boolean; // enable WAF with OWASP CRS in blocking mode +} + +/** + * Create a proxy host via the browser UI. + * ssl_forced is always set to false so functional tests can use plain HTTP. + */ +export async function createProxyHost(page: Page, config: ProxyHostConfig): Promise { + await page.goto('/proxy-hosts'); + await page.getByRole('button', { name: /create host/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByLabel('Name').fill(config.name); + await page.getByLabel(/domains/i).fill(config.domain); + + // Support multiple upstreams separated by newlines. + const upstreamList = config.upstream.split('\n').map((u) => u.trim()).filter(Boolean); + // Fill the first (always-present) upstream input + await page.getByPlaceholder('10.0.0.5:8080').first().fill(upstreamList[0] ?? ''); + // Add additional upstreams via the "Add Upstream" button + for (let i = 1; i < upstreamList.length; i++) { + await page.getByRole('button', { name: /add upstream/i }).click(); + await page.getByPlaceholder('10.0.0.5:8080').nth(i).fill(upstreamList[i]); + } + + if (config.accessListName) { + // MUI TextField select — click to open dropdown, then pick the option + await page.getByRole('combobox', { name: /access list/i }).click(); + await page.getByRole('option', { name: config.accessListName }).click(); + } + + // Inject hidden fields: + // ssl_forced_present=on → tells the action the field was in the form + // (ssl_forced absent) → parseCheckbox(null) = false → no HTTPS redirect + const extraFields: Record = { ssl_forced_present: 'on' }; + + if (config.enableWaf) { + Object.assign(extraFields, { + waf_present: 'on', + waf_enabled: 'on', + waf_engine_mode: 'On', // blocking mode + waf_load_owasp_crs: 'on', + waf_mode: 'override', + }); + } + + await injectFormFields(page, extraFields); + + await page.getByRole('button', { name: /^create$/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(config.name)).toBeVisible({ timeout: 10_000 }); +} + +export interface AccessListUser { + username: string; + password: string; +} + +/** + * Create an access list with initial users via the browser UI. + * Uses the "Seed members" textarea (username:password per line) so all + * users are created atomically with the list — no per-user form needed. + */ +export async function createAccessList( + page: Page, + name: string, + users: AccessListUser[] +): Promise { + await page.goto('/access-lists'); + + await page.getByPlaceholder('Internal users').fill(name); + + if (users.length > 0) { + const seedMembers = users.map((u) => `${u.username}:${u.password}`).join('\n'); + await page.getByLabel('Seed members').fill(seedMembers); + } + + await page.getByRole('button', { name: /create access list/i }).click(); + + // Wait for the card to appear + await expect(page.getByRole('button', { name: /delete list/i })).toBeVisible({ timeout: 10_000 }); +} diff --git a/tests/integration/access-lists-passwords.test.ts b/tests/integration/access-lists-passwords.test.ts new file mode 100644 index 00000000..5f7dfaa8 --- /dev/null +++ b/tests/integration/access-lists-passwords.test.ts @@ -0,0 +1,119 @@ +/** + * Integration tests: bcrypt password hashing in access list entries. + * + * Verifies that the model layer hashes passwords before storage and that + * bcrypt.compare() succeeds with the correct password. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createTestDb, type TestDb } from '../helpers/db'; +import { accessLists, accessListEntries } from '@/src/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import bcrypt from 'bcryptjs'; + +let db: TestDb; + +beforeEach(() => { + db = createTestDb(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function nowIso() { + return new Date().toISOString(); +} + +async function insertList(name = 'Test List') { + const now = nowIso(); + const [list] = await db.insert(accessLists).values({ name, description: null, createdAt: now, updatedAt: now }).returning(); + return list; +} + +async function insertEntry(accessListId: number, username: string, rawPassword: string) { + const now = nowIso(); + const hash = bcrypt.hashSync(rawPassword, 10); + const [entry] = await db.insert(accessListEntries).values({ + accessListId, + username, + passwordHash: hash, + createdAt: now, + updatedAt: now, + }).returning(); + return entry; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('access-lists password hashing', () => { + it('stores a bcrypt hash, not the plain-text password', async () => { + const list = await insertList(); + const entry = await insertEntry(list.id, 'alice', 'S3cr3tP@ss!'); + + const row = await db.query.accessListEntries.findFirst({ where: (t, { eq }) => eq(t.id, entry.id) }); + expect(row).toBeDefined(); + expect(row!.passwordHash).not.toBe('S3cr3tP@ss!'); + expect(row!.passwordHash).toMatch(/^\$2[aby]\$/); + }); + + it('stored hash validates against the correct password', async () => { + const list = await insertList(); + await insertEntry(list.id, 'bob', 'MyPassword123!'); + + const row = await db.query.accessListEntries.findFirst({ + where: (t, { eq }) => eq(t.username, 'bob'), + }); + expect(row).toBeDefined(); + expect(bcrypt.compareSync('MyPassword123!', row!.passwordHash)).toBe(true); + }); + + it('stored hash does NOT validate against a wrong password', async () => { + const list = await insertList(); + await insertEntry(list.id, 'charlie', 'CorrectPassword!'); + + const row = await db.query.accessListEntries.findFirst({ + where: (t, { eq }) => eq(t.username, 'charlie'), + }); + expect(bcrypt.compareSync('WrongPassword!', row!.passwordHash)).toBe(false); + }); + + it('two users with the same password get different hashes (bcrypt salting)', async () => { + const list = await insertList(); + await insertEntry(list.id, 'user1', 'SharedPassword!'); + await insertEntry(list.id, 'user2', 'SharedPassword!'); + + const entries = await db.select().from(accessListEntries).where(eq(accessListEntries.accessListId, list.id)); + expect(entries.length).toBe(2); + // Hashes must differ due to random salt + expect(entries[0].passwordHash).not.toBe(entries[1].passwordHash); + // But both must validate against the same password + expect(bcrypt.compareSync('SharedPassword!', entries[0].passwordHash)).toBe(true); + expect(bcrypt.compareSync('SharedPassword!', entries[1].passwordHash)).toBe(true); + }); + + it('username is stored as-is (not hashed)', async () => { + const list = await insertList(); + await insertEntry(list.id, 'testuser', 'password'); + + const row = await db.query.accessListEntries.findFirst({ + where: (t, { eq }) => eq(t.username, 'testuser'), + }); + expect(row!.username).toBe('testuser'); + }); + + it('each list has independent entries', async () => { + const list1 = await insertList('List A'); + const list2 = await insertList('List B'); + await insertEntry(list1.id, 'shared-user', 'passA'); + await insertEntry(list2.id, 'shared-user', 'passB'); + + const a = await db.query.accessListEntries.findFirst({ where: (t, { eq }) => eq(t.accessListId, list1.id) }); + const b = await db.query.accessListEntries.findFirst({ where: (t, { eq }) => eq(t.accessListId, list2.id) }); + expect(bcrypt.compareSync('passA', a!.passwordHash)).toBe(true); + expect(bcrypt.compareSync('passB', b!.passwordHash)).toBe(true); + // Different passwords → different hashes + expect(bcrypt.compareSync('passA', b!.passwordHash)).toBe(false); + }); +}); diff --git a/tests/integration/proxy-hosts-meta.test.ts b/tests/integration/proxy-hosts-meta.test.ts new file mode 100644 index 00000000..2ff4e005 --- /dev/null +++ b/tests/integration/proxy-hosts-meta.test.ts @@ -0,0 +1,241 @@ +/** + * Integration tests: proxy host JSON field serialization. + * + * Verifies that complex nested meta objects (WAF, geo-block, authentik, + * load balancer) survive a round-trip through the database — stored as JSON, + * retrieved and deserialized correctly. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb, type TestDb } from '../helpers/db'; +import { proxyHosts } from '@/src/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +let db: TestDb; + +beforeEach(() => { + db = createTestDb(); +}); + +function nowIso() { + return new Date().toISOString(); +} + +async function insertHost(overrides: Partial = {}) { + const now = nowIso(); + const [host] = await db.insert(proxyHosts).values({ + name: 'Test Host', + domains: JSON.stringify(['test.example.com']), + upstreams: JSON.stringify(['backend:8080']), + certificateId: null, + accessListId: null, + sslForced: 0, + hstsEnabled: 0, + hstsSubdomains: 0, + allowWebsocket: 0, + preserveHostHeader: 0, + skipHttpsHostnameValidation: 0, + meta: null, + enabled: 1, + createdAt: now, + updatedAt: now, + ...overrides, + }).returning(); + return host; +} + +// --------------------------------------------------------------------------- +// domains / upstreams JSON +// --------------------------------------------------------------------------- + +describe('proxy-hosts JSON fields', () => { + it('stores and retrieves domains array', async () => { + const domains = ['example.com', 'www.example.com']; + const host = await insertHost({ domains: JSON.stringify(domains) }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + expect(JSON.parse(row!.domains)).toEqual(domains); + }); + + it('stores and retrieves multiple upstreams', async () => { + const upstreams = ['backend1:8080', 'backend2:8080', 'backend3:8080']; + const host = await insertHost({ upstreams: JSON.stringify(upstreams) }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + expect(JSON.parse(row!.upstreams)).toEqual(upstreams); + }); + + it('stores upstream with URL containing commas without splitting', () => { + // URLs with commas in query strings must survive round-trip intact + const upstreams = ['http://backend.local/api?a=1,b=2']; + const stored = JSON.stringify(upstreams); + const retrieved = JSON.parse(stored); + expect(retrieved).toEqual(upstreams); + }); +}); + +// --------------------------------------------------------------------------- +// WAF meta round-trip +// --------------------------------------------------------------------------- + +describe('proxy-hosts WAF meta', () => { + it('stores and retrieves WAF config with OWASP CRS enabled', async () => { + const wafMeta = { + waf: { + enabled: true, + mode: 'On', + load_owasp_crs: true, + excluded_rule_ids: [942100, 941110], + waf_mode: 'override', + custom_directives: 'SecRuleEngine On', + }, + }; + const host = await insertHost({ meta: JSON.stringify(wafMeta) }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const parsed = JSON.parse(row!.meta!); + expect(parsed.waf.enabled).toBe(true); + expect(parsed.waf.load_owasp_crs).toBe(true); + expect(parsed.waf.excluded_rule_ids).toEqual([942100, 941110]); + expect(parsed.waf.waf_mode).toBe('override'); + }); + + it('stores and retrieves disabled WAF config', async () => { + const meta = { waf: { enabled: false, waf_mode: 'merge' } }; + const host = await insertHost({ meta: JSON.stringify(meta) }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const parsed = JSON.parse(row!.meta!); + expect(parsed.waf.enabled).toBe(false); + expect(parsed.waf.waf_mode).toBe('merge'); + }); +}); + +// --------------------------------------------------------------------------- +// Geo-block meta round-trip +// --------------------------------------------------------------------------- + +describe('proxy-hosts geo-block meta', () => { + it('stores and retrieves geo-block block list config', async () => { + const meta = { + geoblock_mode: 'block', + geoblock: { + block_countries: ['RU', 'CN', 'KP'], + allow_countries: [], + block_asns: [12345], + block_ips: ['1.2.3.4'], + block_cidrs: ['5.6.0.0/16'], + response_status_code: 403, + fail_closed: true, + }, + }; + const host = await insertHost({ meta: JSON.stringify(meta) }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const parsed = JSON.parse(row!.meta!); + expect(parsed.geoblock_mode).toBe('block'); + expect(parsed.geoblock.block_countries).toEqual(['RU', 'CN', 'KP']); + expect(parsed.geoblock.block_asns).toEqual([12345]); + expect(parsed.geoblock.response_status_code).toBe(403); + expect(parsed.geoblock.fail_closed).toBe(true); + }); + + it('stores and retrieves geo-block allow list config', async () => { + const meta = { + geoblock_mode: 'block', + geoblock: { + block_countries: [], + allow_countries: ['FI', 'SE', 'NO'], + response_status_code: 403, + fail_closed: false, + }, + }; + const host = await insertHost({ meta: JSON.stringify(meta) }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const parsed = JSON.parse(row!.meta!); + expect(parsed.geoblock.allow_countries).toEqual(['FI', 'SE', 'NO']); + expect(parsed.geoblock.fail_closed).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Load balancer meta round-trip +// --------------------------------------------------------------------------- + +describe('proxy-hosts load balancer meta', () => { + it('stores and retrieves load balancer config with active health checks', async () => { + const meta = { + load_balancer: { + enabled: true, + policy: 'round_robin', + active_health_check: { + enabled: true, + uri: '/health', + port: 8081, + interval: '30s', + timeout: '5s', + expected_status: 200, + }, + passive_health_check: { + enabled: false, + }, + cookie_secret: null, + header_field: null, + }, + }; + const host = await insertHost({ meta: JSON.stringify(meta) }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const parsed = JSON.parse(row!.meta!); + expect(parsed.load_balancer.policy).toBe('round_robin'); + expect(parsed.load_balancer.active_health_check.uri).toBe('/health'); + expect(parsed.load_balancer.active_health_check.expected_status).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// Boolean fields +// --------------------------------------------------------------------------- + +describe('proxy-hosts boolean fields', () => { + it('sslForced is stored and retrieved truthy', async () => { + const host = await insertHost({ sslForced: 1 }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + // Drizzle may return SQLite 0/1 as number or as boolean depending on schema mode + expect(Boolean(row!.sslForced)).toBe(true); + }); + + it('hstsEnabled and hstsSubdomains round-trip correctly', async () => { + const host = await insertHost({ hstsEnabled: 1, hstsSubdomains: 1 }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + expect(Boolean(row!.hstsEnabled)).toBe(true); + expect(Boolean(row!.hstsSubdomains)).toBe(true); + }); + + it('allowWebsocket defaults to falsy when not set', async () => { + const host = await insertHost(); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + expect(Boolean(row!.allowWebsocket)).toBe(false); + }); + + it('enabled can be set to disabled (falsy)', async () => { + const host = await insertHost({ enabled: 0 }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + expect(Boolean(row!.enabled)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Null meta field +// --------------------------------------------------------------------------- + +describe('proxy-hosts null meta', () => { + it('meta can be null for simple hosts', async () => { + const host = await insertHost({ meta: null }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + expect(row!.meta).toBeNull(); + }); + + it('multiple hosts can coexist with different meta states', async () => { + const h1 = await insertHost({ name: 'Simple', meta: null }); + const h2 = await insertHost({ name: 'With WAF', meta: JSON.stringify({ waf: { enabled: true, waf_mode: 'override' } }) }); + + const r1 = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, h1.id) }); + const r2 = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, h2.id) }); + expect(r1!.meta).toBeNull(); + expect(JSON.parse(r2!.meta!).waf.enabled).toBe(true); + }); +}); diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index c288b31b..79b8fd77 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ fullyParallel: false, workers: 2, retries: 0, - timeout: 30_000, + timeout: 60_000, // functional tests need time for Caddy reloads reporter: 'list', use: { baseURL: 'http://localhost:3000', diff --git a/tests/unit/caddy-utils.test.ts b/tests/unit/caddy-utils.test.ts new file mode 100644 index 00000000..040a957f --- /dev/null +++ b/tests/unit/caddy-utils.test.ts @@ -0,0 +1,383 @@ +/** + * Unit tests for src/lib/caddy-utils.ts + * Pure functions only — no DB, network, or filesystem. + */ +import { describe, it, expect } from 'vitest'; +import { + expandPrivateRanges, + PRIVATE_RANGES_CIDRS, + mergeDeep, + parseJson, + parseOptionalJson, + parseCustomHandlers, + parseHostPort, + parseUpstreamTarget, + formatDialAddress, + toDurationMs, +} from '@/src/lib/caddy-utils'; + +// --------------------------------------------------------------------------- +// expandPrivateRanges +// --------------------------------------------------------------------------- + +describe('expandPrivateRanges', () => { + it('returns array unchanged when "private_ranges" is absent', () => { + expect(expandPrivateRanges(['10.0.0.1', '192.168.1.0/24'])).toEqual([ + '10.0.0.1', + '192.168.1.0/24', + ]); + }); + + it('replaces "private_ranges" with all private CIDRs', () => { + const result = expandPrivateRanges(['private_ranges']); + expect(result).toEqual(PRIVATE_RANGES_CIDRS); + }); + + it('preserves other entries alongside expanded private_ranges', () => { + const result = expandPrivateRanges(['1.2.3.4', 'private_ranges', '5.6.7.8']); + expect(result).toContain('1.2.3.4'); + expect(result).toContain('5.6.7.8'); + for (const cidr of PRIVATE_RANGES_CIDRS) { + expect(result).toContain(cidr); + } + }); + + it('handles empty array', () => { + expect(expandPrivateRanges([])).toEqual([]); + }); + + it('handles multiple private_ranges occurrences', () => { + const result = expandPrivateRanges(['private_ranges', 'private_ranges']); + expect(result.length).toBe(PRIVATE_RANGES_CIDRS.length * 2); + }); +}); + +// --------------------------------------------------------------------------- +// mergeDeep +// --------------------------------------------------------------------------- + +describe('mergeDeep', () => { + it('merges top-level keys', () => { + const target = { a: 1 }; + mergeDeep(target, { b: 2 }); + expect(target).toEqual({ a: 1, b: 2 }); + }); + + it('overwrites primitive values', () => { + const target: Record = { a: 1 }; + mergeDeep(target, { a: 99 }); + expect(target.a).toBe(99); + }); + + it('deep-merges nested objects', () => { + const target: Record = { a: { x: 1 } }; + mergeDeep(target, { a: { y: 2 } }); + expect(target).toEqual({ a: { x: 1, y: 2 } }); + }); + + it('replaces arrays (does not concat)', () => { + const target: Record = { arr: [1, 2] }; + mergeDeep(target, { arr: [3, 4, 5] }); + expect(target.arr).toEqual([3, 4, 5]); + }); + + it('blocks __proto__ pollution', () => { + const target: Record = {}; + mergeDeep(target, JSON.parse('{"__proto__":{"polluted":true}}')); + // The OWN property list must not contain __proto__ + expect(Object.prototype.hasOwnProperty.call(target, '__proto__')).toBe(false); + // Object.prototype must not have been polluted + expect((Object.prototype as Record).polluted).toBeUndefined(); + }); + + it('blocks constructor pollution', () => { + const target: Record = {}; + mergeDeep(target, { constructor: { name: 'hacked' } }); + // No own property named 'constructor' should have been set + expect(Object.prototype.hasOwnProperty.call(target, 'constructor')).toBe(false); + }); + + it('blocks prototype key', () => { + const target: Record = {}; + mergeDeep(target, { prototype: { evil: true } }); + expect(Object.prototype.hasOwnProperty.call(target, 'prototype')).toBe(false); + }); + + it('handles deeply nested merge without pollution', () => { + const target: Record = { outer: { inner: { val: 1 } } }; + mergeDeep(target, { outer: { inner: { extra: 2 } } }); + expect((target.outer as Record).inner).toEqual({ val: 1, extra: 2 }); + }); +}); + +// --------------------------------------------------------------------------- +// parseJson +// --------------------------------------------------------------------------- + +describe('parseJson', () => { + it('parses valid JSON', () => { + expect(parseJson('{"a":1}', {})).toEqual({ a: 1 }); + }); + + it('returns fallback for null', () => { + expect(parseJson(null, 42)).toBe(42); + }); + + it('returns fallback for empty string', () => { + expect(parseJson('', { default: true })).toEqual({ default: true }); + }); + + it('returns fallback for malformed JSON', () => { + expect(parseJson('not-json{', 'fallback')).toBe('fallback'); + }); + + it('parses arrays', () => { + expect(parseJson('[1,2,3]', [])).toEqual([1, 2, 3]); + }); +}); + +// --------------------------------------------------------------------------- +// parseOptionalJson +// --------------------------------------------------------------------------- + +describe('parseOptionalJson', () => { + it('returns parsed object', () => { + expect(parseOptionalJson('{"x":1}')).toEqual({ x: 1 }); + }); + + it('returns null for null input', () => { + expect(parseOptionalJson(null)).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(parseOptionalJson(undefined)).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(parseOptionalJson('{bad')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// parseCustomHandlers +// --------------------------------------------------------------------------- + +describe('parseCustomHandlers', () => { + it('parses JSON array of objects', () => { + expect(parseCustomHandlers('[{"handler":"file_server"}]')).toEqual([ + { handler: 'file_server' }, + ]); + }); + + it('wraps single object in array', () => { + expect(parseCustomHandlers('{"handler":"static_response"}')).toEqual([ + { handler: 'static_response' }, + ]); + }); + + it('filters out non-object entries', () => { + expect(parseCustomHandlers('[{"ok":true}, 42, "string", null]')).toEqual([ + { ok: true }, + ]); + }); + + it('returns empty array for null', () => { + expect(parseCustomHandlers(null)).toEqual([]); + }); + + it('returns empty array for malformed JSON', () => { + expect(parseCustomHandlers('{bad')).toEqual([]); + }); + + it('returns empty array for empty array JSON', () => { + expect(parseCustomHandlers('[]')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// parseHostPort +// --------------------------------------------------------------------------- + +describe('parseHostPort', () => { + it('parses hostname:port', () => { + expect(parseHostPort('example.com:8080')).toEqual({ host: 'example.com', port: '8080' }); + }); + + it('parses IPv4:port', () => { + expect(parseHostPort('127.0.0.1:3000')).toEqual({ host: '127.0.0.1', port: '3000' }); + }); + + it('parses IPv6 [addr]:port', () => { + expect(parseHostPort('[::1]:8080')).toEqual({ host: '::1', port: '8080' }); + }); + + it('parses full IPv6 address with port', () => { + expect(parseHostPort('[2001:db8::1]:443')).toEqual({ + host: '2001:db8::1', + port: '443', + }); + }); + + it('returns null for empty string', () => { + expect(parseHostPort('')).toBeNull(); + }); + + it('returns null for hostname without port', () => { + expect(parseHostPort('example.com')).toBeNull(); + }); + + it('returns null for bare IPv6 without brackets', () => { + // Multiple colons without brackets → ambiguous + expect(parseHostPort('::1')).toBeNull(); + }); + + it('returns null for [bracket but no closing bracket', () => { + expect(parseHostPort('[::1')).toBeNull(); + }); + + it('returns null for IPv6 bracket without port colon', () => { + expect(parseHostPort('[::1]')).toBeNull(); + }); + + it('returns null for port-only', () => { + expect(parseHostPort(':8080')).toBeNull(); + }); + + it('returns null for host-only ending with colon', () => { + expect(parseHostPort('example.com:')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// formatDialAddress +// --------------------------------------------------------------------------- + +describe('formatDialAddress', () => { + it('formats IPv4 address normally', () => { + expect(formatDialAddress('10.0.0.1', '8080')).toBe('10.0.0.1:8080'); + }); + + it('wraps IPv6 address in brackets', () => { + expect(formatDialAddress('::1', '8080')).toBe('[::1]:8080'); + }); + + it('wraps full IPv6 in brackets', () => { + expect(formatDialAddress('2001:db8::1', '443')).toBe('[2001:db8::1]:443'); + }); + + it('formats hostname without brackets', () => { + expect(formatDialAddress('example.com', '80')).toBe('example.com:80'); + }); +}); + +// --------------------------------------------------------------------------- +// parseUpstreamTarget +// --------------------------------------------------------------------------- + +describe('parseUpstreamTarget', () => { + it('parses host:port upstream', () => { + const r = parseUpstreamTarget('backend:8080'); + expect(r.scheme).toBeNull(); + expect(r.host).toBe('backend'); + expect(r.port).toBe('8080'); + expect(r.dial).toBe('backend:8080'); + }); + + it('parses http:// URL and defaults port to 80', () => { + const r = parseUpstreamTarget('http://service.local'); + expect(r.scheme).toBe('http'); + expect(r.port).toBe('80'); + }); + + it('parses https:// URL and defaults port to 443', () => { + const r = parseUpstreamTarget('https://service.local'); + expect(r.scheme).toBe('https'); + expect(r.port).toBe('443'); + }); + + it('parses https:// URL with explicit port', () => { + const r = parseUpstreamTarget('https://service.local:8443'); + expect(r.scheme).toBe('https'); + expect(r.port).toBe('8443'); + }); + + it('wraps IPv6 dial address in brackets', () => { + const r = parseUpstreamTarget('[::1]:9000'); + expect(r.dial).toBe('[::1]:9000'); + }); + + it('handles empty string gracefully', () => { + const r = parseUpstreamTarget(''); + expect(r.scheme).toBeNull(); + expect(r.host).toBeNull(); + }); + + it('handles unparseable non-URL string gracefully', () => { + const r = parseUpstreamTarget('not-valid-upstream'); + expect(r.scheme).toBeNull(); + expect(r.host).toBeNull(); + expect(r.dial).toBe('not-valid-upstream'); + }); +}); + +// --------------------------------------------------------------------------- +// toDurationMs +// --------------------------------------------------------------------------- + +describe('toDurationMs', () => { + it('parses seconds', () => { + expect(toDurationMs('5s')).toBe(5000); + }); + + it('parses milliseconds', () => { + expect(toDurationMs('500ms')).toBe(500); + }); + + it('parses minutes', () => { + expect(toDurationMs('2m')).toBe(120_000); + }); + + it('parses hours', () => { + expect(toDurationMs('1h')).toBe(3_600_000); + }); + + it('parses composite: 1m30s', () => { + expect(toDurationMs('1m30s')).toBe(90_000); + }); + + it('parses composite: 2h30m', () => { + expect(toDurationMs('2h30m')).toBe(9_000_000); + }); + + it('parses decimal seconds', () => { + expect(toDurationMs('1.5s')).toBe(1500); + }); + + it('returns null for null input', () => { + expect(toDurationMs(null)).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(toDurationMs(undefined)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(toDurationMs('')).toBeNull(); + }); + + it('returns null for plain number without unit', () => { + expect(toDurationMs('5000')).toBeNull(); + }); + + it('returns null for invalid text', () => { + expect(toDurationMs('invalid')).toBeNull(); + }); + + it('returns null for partial match with trailing garbage', () => { + expect(toDurationMs('5s garbage')).toBeNull(); + }); + + it('returns null for zero-duration', () => { + expect(toDurationMs('0s')).toBeNull(); + }); +}); diff --git a/tests/unit/form-parse.test.ts b/tests/unit/form-parse.test.ts new file mode 100644 index 00000000..af05e6b9 --- /dev/null +++ b/tests/unit/form-parse.test.ts @@ -0,0 +1,284 @@ +/** + * Unit tests for src/lib/form-parse.ts + * Tests all pure FormData parsing helpers. + */ +import { describe, it, expect } from 'vitest'; +import { + parseCsv, + parseUpstreams, + parseCheckbox, + parseOptionalText, + parseCertificateId, + parseAccessListId, + parseOptionalNumber, +} from '@/src/lib/form-parse'; + +// --------------------------------------------------------------------------- +// parseCsv +// --------------------------------------------------------------------------- + +describe('parseCsv', () => { + it('splits by comma', () => { + expect(parseCsv('a,b,c')).toEqual(['a', 'b', 'c']); + }); + + it('splits by newline (newlines converted to commas)', () => { + expect(parseCsv('a\nb\nc')).toEqual(['a', 'b', 'c']); + }); + + it('trims whitespace from each item', () => { + expect(parseCsv(' a , b ')).toEqual(['a', 'b']); + }); + + it('filters empty items after split', () => { + expect(parseCsv('a,,b,')).toEqual(['a', 'b']); + }); + + it('returns empty array for null', () => { + expect(parseCsv(null)).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(parseCsv('')).toEqual([]); + }); + + it('handles single item without delimiter', () => { + expect(parseCsv('example.com')).toEqual(['example.com']); + }); + + it('handles mixed comma and newline delimiters', () => { + expect(parseCsv('a,b\nc,d')).toEqual(['a', 'b', 'c', 'd']); + }); +}); + +// --------------------------------------------------------------------------- +// parseUpstreams +// --------------------------------------------------------------------------- + +describe('parseUpstreams', () => { + it('splits by newline', () => { + expect(parseUpstreams('http://a\nhttp://b')).toEqual(['http://a', 'http://b']); + }); + + it('does NOT split on commas (URLs may contain commas in query strings)', () => { + const url = 'http://example.com/path?a=1,b=2'; + expect(parseUpstreams(url)).toEqual([url]); + }); + + it('trims whitespace from each line', () => { + expect(parseUpstreams(' backend:8080 \n backend2:9090 ')).toEqual([ + 'backend:8080', + 'backend2:9090', + ]); + }); + + it('filters empty lines', () => { + expect(parseUpstreams('a\n\nb\n')).toEqual(['a', 'b']); + }); + + it('returns empty array for null', () => { + expect(parseUpstreams(null)).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(parseUpstreams('')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// parseCheckbox +// --------------------------------------------------------------------------- + +describe('parseCheckbox', () => { + it('"on" → true', () => { + expect(parseCheckbox('on')).toBe(true); + }); + + it('"true" → true', () => { + expect(parseCheckbox('true')).toBe(true); + }); + + it('"1" → true', () => { + expect(parseCheckbox('1')).toBe(true); + }); + + it('null → false', () => { + expect(parseCheckbox(null)).toBe(false); + }); + + it('"off" → false', () => { + expect(parseCheckbox('off')).toBe(false); + }); + + it('"false" → false', () => { + expect(parseCheckbox('false')).toBe(false); + }); + + it('"0" → false', () => { + expect(parseCheckbox('0')).toBe(false); + }); + + it('empty string → false', () => { + expect(parseCheckbox('')).toBe(false); + }); + + it('arbitrary string → false', () => { + expect(parseCheckbox('yes')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseOptionalText +// --------------------------------------------------------------------------- + +describe('parseOptionalText', () => { + it('returns trimmed string for non-empty input', () => { + expect(parseOptionalText(' hello ')).toBe('hello'); + }); + + it('returns the exact string when already trimmed', () => { + expect(parseOptionalText('hello')).toBe('hello'); + }); + + it('returns null for null', () => { + expect(parseOptionalText(null)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseOptionalText('')).toBeNull(); + }); + + it('returns null for whitespace-only string', () => { + expect(parseOptionalText(' ')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// parseCertificateId +// --------------------------------------------------------------------------- + +describe('parseCertificateId', () => { + it('parses a valid positive integer', () => { + expect(parseCertificateId('42')).toBe(42); + }); + + it('parses "1" as 1', () => { + expect(parseCertificateId('1')).toBe(1); + }); + + it('returns null for null', () => { + expect(parseCertificateId(null)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseCertificateId('')).toBeNull(); + }); + + it('returns null for "0" (must be > 0)', () => { + expect(parseCertificateId('0')).toBeNull(); + }); + + it('returns null for negative numbers', () => { + expect(parseCertificateId('-1')).toBeNull(); + }); + + it('returns null for decimal values', () => { + expect(parseCertificateId('1.5')).toBeNull(); + }); + + it('returns null for NaN', () => { + expect(parseCertificateId('NaN')).toBeNull(); + }); + + it('returns null for Infinity', () => { + expect(parseCertificateId('Infinity')).toBeNull(); + }); + + it('returns null for the literal "null"', () => { + expect(parseCertificateId('null')).toBeNull(); + }); + + it('returns null for the literal "undefined"', () => { + expect(parseCertificateId('undefined')).toBeNull(); + }); + + it('returns null for non-numeric text', () => { + expect(parseCertificateId('abc')).toBeNull(); + }); + + it('handles leading/trailing whitespace', () => { + expect(parseCertificateId(' 5 ')).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// parseAccessListId — identical rules to parseCertificateId +// --------------------------------------------------------------------------- + +describe('parseAccessListId', () => { + it('parses a valid positive integer', () => { + expect(parseAccessListId('7')).toBe(7); + }); + + it('returns null for null', () => { + expect(parseAccessListId(null)).toBeNull(); + }); + + it('returns null for "0"', () => { + expect(parseAccessListId('0')).toBeNull(); + }); + + it('returns null for float', () => { + expect(parseAccessListId('2.5')).toBeNull(); + }); + + it('returns null for NaN', () => { + expect(parseAccessListId('NaN')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// parseOptionalNumber +// --------------------------------------------------------------------------- + +describe('parseOptionalNumber', () => { + it('parses integer', () => { + expect(parseOptionalNumber('42')).toBe(42); + }); + + it('parses float', () => { + expect(parseOptionalNumber('3.14')).toBe(3.14); + }); + + it('parses negative number', () => { + expect(parseOptionalNumber('-5')).toBe(-5); + }); + + it('parses zero', () => { + expect(parseOptionalNumber('0')).toBe(0); + }); + + it('returns null for null', () => { + expect(parseOptionalNumber(null)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseOptionalNumber('')).toBeNull(); + }); + + it('returns null for whitespace-only', () => { + expect(parseOptionalNumber(' ')).toBeNull(); + }); + + it('returns null for NaN text', () => { + expect(parseOptionalNumber('NaN')).toBeNull(); + }); + + it('returns null for Infinity', () => { + expect(parseOptionalNumber('Infinity')).toBeNull(); + }); + + it('returns null for non-numeric text', () => { + expect(parseOptionalNumber('abc')).toBeNull(); + }); +});