refractor code to allow more tests
This commit is contained in:
@@ -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
203
src/lib/caddy-utils.ts
Normal 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;
|
||||
}
|
||||
240
src/lib/caddy.ts
240
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<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
61
src/lib/form-parse.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
68
tests/e2e/functional/access-control.spec.ts
Normal file
68
tests/e2e/functional/access-control.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
tests/e2e/functional/load-balancing.spec.ts
Normal file
60
tests/e2e/functional/load-balancing.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
66
tests/e2e/functional/proxy-routing.spec.ts
Normal file
66
tests/e2e/functional/proxy-routing.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
tests/e2e/functional/ssl-redirect.spec.ts
Normal file
64
tests/e2e/functional/ssl-redirect.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
tests/e2e/functional/waf-blocking.spec.ts
Normal file
68
tests/e2e/functional/waf-blocking.spec.ts
Normal 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
79
tests/helpers/http.ts
Normal 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);
|
||||
}
|
||||
98
tests/helpers/proxy-api.ts
Normal file
98
tests/helpers/proxy-api.ts
Normal 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 });
|
||||
}
|
||||
119
tests/integration/access-lists-passwords.test.ts
Normal file
119
tests/integration/access-lists-passwords.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
241
tests/integration/proxy-hosts-meta.test.ts
Normal file
241
tests/integration/proxy-hosts-meta.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
383
tests/unit/caddy-utils.test.ts
Normal file
383
tests/unit/caddy-utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
284
tests/unit/form-parse.test.ts
Normal file
284
tests/unit/form-parse.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user