refractor code to allow more tests

This commit is contained in:
fuomag9
2026-03-07 16:53:36 +01:00
parent f85c425ac1
commit e5ba3e1ed9
17 changed files with 1833 additions and 323 deletions

View File

@@ -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")),

203
src/lib/caddy-utils.ts Normal file
View File

@@ -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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
// ---------------------------------------------------------------------------
// Deep merge (prototype-pollution safe)
// ---------------------------------------------------------------------------
export function mergeDeep(
target: Record<string, unknown>,
source: Record<string, unknown>
) {
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<T>(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<string, unknown>[] {
const parsed = parseOptionalJson(value);
if (!parsed) return [];
const list = Array.isArray(parsed) ? parsed : [parsed];
const handlers: Record<string, unknown>[] = [];
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;
}

View File

@@ -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<string>;
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function parseJson<T>(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<string, unknown>, source: Record<string, unknown>) {
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<string, unknown>[] {
const parsed = parseOptionalJson(value);
if (!parsed) {
return [];
}
const list = Array.isArray(parsed) ? parsed : [parsed];
const handlers: Record<string, unknown>[] = [];
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 {

61
src/lib/form-parse.ts Normal file
View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <script> tag is blocked (CRS rule 941xxx)', async () => {
// URL-encoded: ?q=<script>alert(1)</script>
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);
});
});

79
tests/helpers/http.ts Normal file
View File

@@ -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<string, string | string[] | undefined>;
body: string;
}
/** Make an HTTP request to Caddy (localhost:80) with a custom Host header. */
export function httpGet(domain: string, path = '/', extraHeaders: Record<string, string> = {}): Promise<HttpResponse> {
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<void> {
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<string, string>): Promise<void> {
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<HTMLInputElement>(`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);
}

View File

@@ -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<void> {
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<string, string> = { 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<void> {
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 });
}

View File

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

View File

@@ -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<typeof proxyHosts.$inferInsert> = {}) {
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);
});
});

View File

@@ -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',

View File

@@ -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<string, unknown> = { a: 1 };
mergeDeep(target, { a: 99 });
expect(target.a).toBe(99);
});
it('deep-merges nested objects', () => {
const target: Record<string, unknown> = { 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<string, unknown> = { arr: [1, 2] };
mergeDeep(target, { arr: [3, 4, 5] });
expect(target.arr).toEqual([3, 4, 5]);
});
it('blocks __proto__ pollution', () => {
const target: Record<string, unknown> = {};
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<string, unknown>).polluted).toBeUndefined();
});
it('blocks constructor pollution', () => {
const target: Record<string, unknown> = {};
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<string, unknown> = {};
mergeDeep(target, { prototype: { evil: true } });
expect(Object.prototype.hasOwnProperty.call(target, 'prototype')).toBe(false);
});
it('handles deeply nested merge without pollution', () => {
const target: Record<string, unknown> = { outer: { inner: { val: 1 } } };
mergeDeep(target, { outer: { inner: { extra: 2 } } });
expect((target.outer as Record<string, unknown>).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();
});
});

View File

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