diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 49893977..8e3f7f46 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -14,7 +14,9 @@ import { type UpstreamDnsResolutionInput, type GeoBlockMode, type WafHostConfig, - type MtlsConfig + type MtlsConfig, + type RedirectRule, + type RewriteConfig } from "@/src/lib/models/proxy-hosts"; import { getCertificate } from "@/src/lib/models/certificates"; import { getCloudflareSettings, type GeoBlockSettings } from "@/src/lib/settings"; @@ -396,6 +398,30 @@ function parseMtlsConfig(formData: FormData): MtlsConfig | null { return { enabled, ca_certificate_ids: ids }; } +function parseRedirectsConfig(formData: FormData): RedirectRule[] | null { + const raw = formData.get("redirects_json"); + if (!raw || typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (r) => + r && + typeof r.from === "string" && + typeof r.to === "string" && + [301, 302, 307, 308].includes(r.status) + ) as RedirectRule[]; + } catch { + return null; + } +} + +function parseRewriteConfig(formData: FormData): RewriteConfig | null { + const prefix = formData.get("rewrite_path_prefix"); + if (!prefix || typeof prefix !== "string" || !prefix.trim()) return null; + return { path_prefix: prefix.trim() }; +} + function parseUpstreamDnsResolutionConfig(formData: FormData): UpstreamDnsResolutionInput | undefined { if (!formData.has("upstream_dns_resolution_present")) { return undefined; @@ -465,7 +491,9 @@ export async function createProxyHostAction( upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData), ...parseGeoBlockConfig(formData), ...parseWafConfig(formData), - mtls: parseMtlsConfig(formData) + mtls: parseMtlsConfig(formData), + redirects: parseRedirectsConfig(formData), + rewrite: parseRewriteConfig(formData), }, userId ); @@ -539,7 +567,9 @@ export async function updateProxyHostAction( upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData), ...parseGeoBlockConfig(formData), ...parseWafConfig(formData), - mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined + mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined, + redirects: formData.has("redirects_json") ? parseRedirectsConfig(formData) : undefined, + rewrite: formData.has("rewrite_path_prefix") ? parseRewriteConfig(formData) : undefined, }, userId ); diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx index f6a58bca..62d77aa2 100644 --- a/src/components/proxy-hosts/HostDialogs.tsx +++ b/src/components/proxy-hosts/HostDialogs.tsx @@ -22,6 +22,8 @@ import { UpstreamInput } from "./UpstreamInput"; import { GeoBlockFields } from "./GeoBlockFields"; import { WafFields } from "./WafFields"; import { MtlsFields } from "./MtlsConfig"; +import { RedirectsFields } from "./RedirectsFields"; +import { RewriteFields } from "./RewriteFields"; import type { CaCertificate } from "@/src/lib/models/ca-certificates"; export function CreateHostDialog({ @@ -108,6 +110,8 @@ export function CreateHostDialog({ ))} + + ))} + + (initialData); + + const addRule = () => + setRules((r) => [...r, { from: "", to: "", status: 301 }]); + + const removeRule = (i: number) => + setRules((r) => r.filter((_, idx) => idx !== i)); + + const updateRule = (i: number, key: keyof RedirectRule, value: string | number) => + setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, [key]: value } : rule))); + + return ( + + + Redirects + + + {rules.length > 0 && ( + + + + From Path + To URL / Path + Status + + + + + {rules.map((rule, i) => ( + + + updateRule(i, "from", e.target.value)} + fullWidth + /> + + + updateRule(i, "to", e.target.value)} + fullWidth + /> + + + + + + removeRule(i)}> + + + + + ))} + +
+ )} + +
+ ); +} diff --git a/src/components/proxy-hosts/RewriteFields.tsx b/src/components/proxy-hosts/RewriteFields.tsx new file mode 100644 index 00000000..0c8ee84c --- /dev/null +++ b/src/components/proxy-hosts/RewriteFields.tsx @@ -0,0 +1,17 @@ +import { TextField } from "@mui/material"; +import type { RewriteConfig } from "@/src/lib/models/proxy-hosts"; + +type Props = { initialData?: RewriteConfig | null }; + +export function RewriteFields({ initialData }: Props) { + return ( + + ); +} diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index d6399ec5..e77c7914 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -49,7 +49,7 @@ import { issuedClientCertificates, proxyHosts } from "./db/schema"; -import { type GeoBlockMode, type WafHostConfig, type MtlsConfig } from "./models/proxy-hosts"; +import { type GeoBlockMode, type WafHostConfig, type MtlsConfig, type RedirectRule, type RewriteConfig } from "./models/proxy-hosts"; import { buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls"; import { buildWafHandler, resolveEffectiveWaf } from "./caddy-waf"; @@ -113,6 +113,8 @@ type ProxyHostMeta = { geoblock?: GeoBlockSettings; geoblock_mode?: GeoBlockMode; waf?: WafHostConfig; + redirects?: RedirectRule[]; + rewrite?: RewriteConfig; }; type ProxyHostAuthentikMeta = { @@ -700,6 +702,22 @@ async function buildProxyRoutes( } } + // Structured redirects — emitted before auth so .well-known paths work without login + if (meta.redirects && meta.redirects.length > 0) { + const redirectRoutes = meta.redirects.map((rule) => ({ + match: [{ path: [rule.from] }], + handle: [{ + handler: "static_response", + status_code: rule.status, + headers: { Location: [rule.to] }, + }], + })); + handlers.push({ + handler: "subroute", + routes: redirectRoutes, + }); + } + if (row.access_list_id) { const accounts = accessAccounts.get(row.access_list_id) ?? []; if (accounts.length > 0) { @@ -850,6 +868,14 @@ async function buildProxyRoutes( } } + // Structured path prefix rewrite + if (meta.rewrite?.path_prefix) { + handlers.push({ + handler: "rewrite", + uri: `${meta.rewrite.path_prefix}{http.request.uri}`, + }); + } + const customHandlers = parseCustomHandlers(meta.custom_pre_handlers_json); if (customHandlers.length > 0) { handlers.push(...customHandlers); diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index 3a74c5c2..712537ff 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -28,6 +28,16 @@ export type GeoBlockMode = "merge" | "override"; export type WafMode = "merge" | "override"; +export type RedirectRule = { + from: string; // path pattern e.g. "/.well-known/carddav" + to: string; // destination e.g. "/remote.php/dav/" + status: 301 | 302 | 307 | 308; +}; + +export type RewriteConfig = { + path_prefix: string; // e.g. "/recipes" +}; + export type WafHostConfig = { enabled?: boolean; mode?: 'Off' | 'On'; @@ -217,6 +227,8 @@ type ProxyHostMeta = { geoblock_mode?: GeoBlockMode; waf?: WafHostConfig; mtls?: MtlsConfig; + redirects?: RedirectRule[]; + rewrite?: RewriteConfig; }; export type ProxyHost = { @@ -245,6 +257,8 @@ export type ProxyHost = { geoblock_mode: GeoBlockMode; waf: WafHostConfig | null; mtls: MtlsConfig | null; + redirects: RedirectRule[]; + rewrite: RewriteConfig | null; }; export type ProxyHostInput = { @@ -270,6 +284,8 @@ export type ProxyHostInput = { geoblock_mode?: GeoBlockMode; waf?: WafHostConfig | null; mtls?: MtlsConfig | null; + redirects?: RedirectRule[] | null; + rewrite?: RewriteConfig | null; }; type ProxyHostRow = typeof proxyHosts.$inferSelect; @@ -551,9 +567,41 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) { normalized.mtls = meta.mtls; } + if (meta.redirects && meta.redirects.length > 0) { + normalized.redirects = meta.redirects; + } + if (meta.rewrite?.path_prefix) { + normalized.rewrite = meta.rewrite; + } + return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null; } +function sanitizeRedirectRules(value: unknown): RedirectRule[] { + if (!Array.isArray(value)) return []; + const valid: RedirectRule[] = []; + for (const item of value) { + if ( + item && + typeof item === "object" && + typeof item.from === "string" && item.from.trim() && + typeof item.to === "string" && item.to.trim() && + [301, 302, 307, 308].includes(item.status) + ) { + valid.push({ from: item.from.trim(), to: item.to.trim(), status: item.status }); + } + } + return valid; +} + +function sanitizeRewriteConfig(value: unknown): RewriteConfig | null { + if (!value || typeof value !== "object") return null; + const v = value as Record; + const prefix = typeof v.path_prefix === "string" ? v.path_prefix.trim() : null; + if (!prefix) return null; + return { path_prefix: prefix }; +} + function parseMeta(value: string | null): ProxyHostMeta { if (!value) { return {}; @@ -571,6 +619,8 @@ function parseMeta(value: string | null): ProxyHostMeta { geoblock_mode: parsed.geoblock_mode, waf: parsed.waf, mtls: parsed.mtls, + redirects: sanitizeRedirectRules(parsed.redirects), + rewrite: sanitizeRewriteConfig(parsed.rewrite) ?? undefined, }; } catch (error) { console.warn("Failed to parse proxy host meta", error); @@ -1045,6 +1095,24 @@ function buildMeta(existing: ProxyHostMeta, input: Partial): str } } + if (input.redirects !== undefined) { + const rules = sanitizeRedirectRules(input.redirects ?? []); + if (rules.length > 0) { + next.redirects = rules; + } else { + delete next.redirects; + } + } + + if (input.rewrite !== undefined) { + const rw = sanitizeRewriteConfig(input.rewrite); + if (rw) { + next.rewrite = rw; + } else { + delete next.rewrite; + } + } + return serializeMeta(next); } @@ -1372,6 +1440,8 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost { geoblock_mode: meta.geoblock_mode ?? "merge", waf: meta.waf ?? null, mtls: meta.mtls ?? null, + redirects: meta.redirects ?? [], + rewrite: meta.rewrite ?? null, }; } diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml index 815872b1..2afefb48 100644 --- a/tests/docker-compose.test.yml +++ b/tests/docker-compose.test.yml @@ -24,6 +24,12 @@ services: command: ["-text=echo-server-2", "-listen=:8080"] networks: - caddy-network + # Request-echo server: reflects the full HTTP request (method + path + headers) in the response body. + # Used by path-prefix-rewrite tests to assert that Caddy rewrote the path before forwarding. + whoami-server: + image: traefik/whoami + networks: + - caddy-network volumes: caddy-manager-data: name: caddy-manager-data-test diff --git a/tests/e2e/functional/path-prefix-rewrite.spec.ts b/tests/e2e/functional/path-prefix-rewrite.spec.ts new file mode 100644 index 00000000..64ff74e2 --- /dev/null +++ b/tests/e2e/functional/path-prefix-rewrite.spec.ts @@ -0,0 +1,63 @@ +/** + * Functional tests: path prefix rewrite. + * + * Creates a proxy host with a path prefix rewrite (/api) pointing at the + * whoami-server, which reflects the full request line in its response body. + * This lets us assert that Caddy rewrote the path before forwarding, e.g. + * a client request for /users arrives at the upstream as /api/users. + * + * Domain: func-rewrite.test + */ +import { test, expect } from '@playwright/test'; +import { httpGet, injectFormFields, waitForRoute } from '../../helpers/http'; + +const DOMAIN = 'func-rewrite.test'; + +test.describe.serial('Path Prefix Rewrite', () => { + test('setup: create proxy host with path prefix rewrite', async ({ page }) => { + 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 Path Prefix Rewrite Test'); + await page.getByLabel(/domains/i).fill(DOMAIN); + // whoami-server listens on port 80 by default + await page.getByPlaceholder('10.0.0.5:8080').first().fill('whoami-server:80'); + + // Fill in the path prefix rewrite field + await page.getByLabel('Path Prefix Rewrite').fill('/api'); + + await injectFormFields(page, { ssl_forced_present: 'on' }); + await page.getByRole('button', { name: /^create$/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('Functional Path Prefix Rewrite Test')).toBeVisible({ timeout: 10_000 }); + + await waitForRoute(DOMAIN); + }); + + test('request path is prepended with the prefix before reaching the upstream', async () => { + const res = await httpGet(DOMAIN, '/users'); + expect(res.status).toBe(200); + // traefik/whoami echoes the request line, e.g. "GET /api/users HTTP/1.1" + expect(res.body).toContain('/api/users'); + }); + + test('root path is prepended with the prefix', async () => { + const res = await httpGet(DOMAIN, '/'); + expect(res.status).toBe(200); + expect(res.body).toContain('/api/'); + }); + + test('nested path is prepended with the prefix', async () => { + const res = await httpGet(DOMAIN, '/items/42/details'); + expect(res.status).toBe(200); + expect(res.body).toContain('/api/items/42/details'); + }); + + test('original path without prefix is NOT sent to the upstream', async () => { + const res = await httpGet(DOMAIN, '/users'); + expect(res.status).toBe(200); + // The upstream must NOT see the bare /users path — it should see /api/users + expect(res.body).not.toMatch(/^GET \/users /m); + }); +}); diff --git a/tests/e2e/functional/redirects-advanced.spec.ts b/tests/e2e/functional/redirects-advanced.spec.ts new file mode 100644 index 00000000..843882f0 --- /dev/null +++ b/tests/e2e/functional/redirects-advanced.spec.ts @@ -0,0 +1,192 @@ +/** + * Functional tests: redirect rules with full URLs, cross-domain destinations, + * and wildcard path patterns. + * + * All tests send real HTTP requests to Caddy (port 80) with a custom Host + * header and assert the response from Caddy — no redirect following. + * + * Caddy path matcher wildcard behaviour (used in the "from" field): + * - Exact: "/foo/bar" — only that path + * - Suffix glob: "/foo/bar*" — anything starting with /foo/bar + * - Dir glob: "/foo/*" — anything under /foo/ (requires the slash) + * + * Domain: func-redirects-adv.test + */ +import { test, expect } from '@playwright/test'; +import { httpGet, injectFormFields, waitForRoute } from '../../helpers/http'; + +const DOMAIN = 'func-redirects-adv.test'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extract the Location header value from a response, normalised to a string. */ +function location(res: Awaited>): string { + const h = res.headers['location']; + return Array.isArray(h) ? h[0] : (h ?? ''); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +test.describe.serial('Redirect Rules – full URLs, cross-domain, wildcards', () => { + test('setup: create proxy host with advanced redirect rules', async ({ page }) => { + 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 Advanced Redirects Test'); + await page.getByLabel(/domains/i).fill(DOMAIN); + await page.getByPlaceholder('10.0.0.5:8080').first().fill('echo-server:8080'); + + await injectFormFields(page, { + ssl_forced_present: 'on', + redirects_json: JSON.stringify([ + // ── full absolute URL destinations ────────────────────────────────── + // Exact path → full URL on a completely different host (301) + { from: '/old-page', to: 'https://new-site.example.com/page', status: 301 }, + // Exact path → full URL with path on another domain (308 permanent) + { from: '/docs', to: 'https://docs.example.com/v2/', status: 308 }, + // Exact path → full URL using http:// scheme (302 temporary) + { from: '/insecure-legacy', to: 'http://legacy.example.com/', status: 302 }, + + // ── wildcard "from" → relative destination ────────────────────────── + // /.well-known/* → any well-known path redirected to /dav/ (301) + { from: '/.well-known/*', to: '/dav/', status: 301 }, + // /api/v1/* → all v1 endpoints redirected to /api/v2/ (302) + { from: '/api/v1/*', to: '/api/v2/', status: 302 }, + // /legacy* → bare prefix (no slash after) covers /legacy and /legacy/* (307) + { from: '/legacy*', to: '/current/', status: 307 }, + + // ── wildcard "from" → full URL destination ────────────────────────── + // /moved/* → absolute URL on another domain (308) + { from: '/moved/*', to: 'https://archive.example.com/', status: 308 }, + ]), + }); + + await page.getByRole('button', { name: /^create$/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('Functional Advanced Redirects Test')).toBeVisible({ timeout: 10_000 }); + + await waitForRoute(DOMAIN); + }); + + // ── full absolute URL destinations ───────────────────────────────────────── + + test('exact path → full URL: status is 301', async () => { + const res = await httpGet(DOMAIN, '/old-page'); + expect(res.status).toBe(301); + }); + + test('exact path → full URL: Location is the full absolute URL', async () => { + const res = await httpGet(DOMAIN, '/old-page'); + expect(location(res)).toBe('https://new-site.example.com/page'); + }); + + test('exact path → full URL on another domain: status is 308', async () => { + const res = await httpGet(DOMAIN, '/docs'); + expect(res.status).toBe(308); + }); + + test('exact path → full URL on another domain: Location preserves path', async () => { + const res = await httpGet(DOMAIN, '/docs'); + expect(location(res)).toBe('https://docs.example.com/v2/'); + }); + + test('exact path → http:// URL: status is 302', async () => { + const res = await httpGet(DOMAIN, '/insecure-legacy'); + expect(res.status).toBe(302); + }); + + test('exact path → http:// URL: Location uses http scheme', async () => { + const res = await httpGet(DOMAIN, '/insecure-legacy'); + expect(location(res)).toMatch(/^http:\/\//); + expect(location(res)).toBe('http://legacy.example.com/'); + }); + + // ── wildcard "from" → relative destination ───────────────────────────────── + + test('wildcard /.well-known/*: first subpath redirects with 301', async () => { + const res = await httpGet(DOMAIN, '/.well-known/carddav'); + expect(res.status).toBe(301); + expect(location(res)).toBe('/dav/'); + }); + + test('wildcard /.well-known/*: second subpath also redirects with 301', async () => { + const res = await httpGet(DOMAIN, '/.well-known/caldav'); + expect(res.status).toBe(301); + expect(location(res)).toBe('/dav/'); + }); + + test('wildcard /.well-known/*: deeply nested subpath redirects with 301', async () => { + const res = await httpGet(DOMAIN, '/.well-known/openid-configuration'); + expect(res.status).toBe(301); + expect(location(res)).toBe('/dav/'); + }); + + test('wildcard /api/v1/*: first v1 endpoint redirects with 302', async () => { + const res = await httpGet(DOMAIN, '/api/v1/users'); + expect(res.status).toBe(302); + expect(location(res)).toBe('/api/v2/'); + }); + + test('wildcard /api/v1/*: second v1 endpoint also redirects with 302', async () => { + const res = await httpGet(DOMAIN, '/api/v1/orders/42'); + expect(res.status).toBe(302); + expect(location(res)).toBe('/api/v2/'); + }); + + test('wildcard /api/v2/* is not matched (different prefix)', async () => { + const res = await httpGet(DOMAIN, '/api/v2/users'); + expect(res.status).toBe(200); + expect(res.body).toContain('echo-ok'); + }); + + test('bare-prefix wildcard /legacy*: matches path without trailing segment', async () => { + const res = await httpGet(DOMAIN, '/legacy'); + expect(res.status).toBe(307); + expect(location(res)).toBe('/current/'); + }); + + test('bare-prefix wildcard /legacy*: matches path with trailing segment', async () => { + const res = await httpGet(DOMAIN, '/legacy/old-feature'); + expect(res.status).toBe(307); + expect(location(res)).toBe('/current/'); + }); + + test('bare-prefix wildcard /legacy*: matches path with suffix (no slash)', async () => { + const res = await httpGet(DOMAIN, '/legacy-stuff'); + expect(res.status).toBe(307); + expect(location(res)).toBe('/current/'); + }); + + // ── wildcard "from" → full URL destination ───────────────────────────────── + + test('wildcard /moved/* → absolute URL: first subpath redirects with 308', async () => { + const res = await httpGet(DOMAIN, '/moved/post-1'); + expect(res.status).toBe(308); + expect(location(res)).toBe('https://archive.example.com/'); + }); + + test('wildcard /moved/* → absolute URL: second subpath also redirects with 308', async () => { + const res = await httpGet(DOMAIN, '/moved/category/post-2'); + expect(res.status).toBe(308); + expect(location(res)).toBe('https://archive.example.com/'); + }); + + // ── unmatched paths still reach the upstream ─────────────────────────────── + + test('path matching no rule is proxied to the upstream', async () => { + const res = await httpGet(DOMAIN, '/some/other/path'); + expect(res.status).toBe(200); + expect(res.body).toContain('echo-ok'); + }); + + test('root path matching no rule is proxied to the upstream', async () => { + const res = await httpGet(DOMAIN, '/'); + expect(res.status).toBe(200); + expect(res.body).toContain('echo-ok'); + }); +}); diff --git a/tests/e2e/functional/redirects.spec.ts b/tests/e2e/functional/redirects.spec.ts new file mode 100644 index 00000000..940eb2fe --- /dev/null +++ b/tests/e2e/functional/redirects.spec.ts @@ -0,0 +1,77 @@ +/** + * Functional tests: per-path redirect rules. + * + * Creates a proxy host with structured redirect rules and verifies that + * Caddy issues the correct redirect responses for matched paths while + * still proxying unmatched paths to the upstream. + * + * The redirects_json hidden field is injected directly (same pattern used + * for other non-labeled form controls like ssl_forced_present) so the test + * doesn't have to click through the MUI Select for each status code. + * + * Domain: func-redirects.test + */ +import { test, expect } from '@playwright/test'; +import { httpGet, injectFormFields, waitForRoute } from '../../helpers/http'; + +const DOMAIN = 'func-redirects.test'; + +test.describe.serial('Per-path Redirect Rules', () => { + test('setup: create proxy host with redirect rules', async ({ page }) => { + 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 Redirects Test'); + await page.getByLabel(/domains/i).fill(DOMAIN); + await page.getByPlaceholder('10.0.0.5:8080').first().fill('echo-server:8080'); + + // Inject redirect rules and form flags directly. + // redirects_json is a hidden input rendered by RedirectsFields whose value + // reflects React state; setting .value just before submit works because no + // React render cycle fires between the injection and form data collection. + await injectFormFields(page, { + ssl_forced_present: 'on', + redirects_json: JSON.stringify([ + { from: '/.well-known/carddav', to: '/remote.php/dav/', status: 301 }, + { from: '/.well-known/caldav', to: '/remote.php/dav/', status: 302 }, + ]), + }); + + await page.getByRole('button', { name: /^create$/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('Functional Redirects Test')).toBeVisible({ timeout: 10_000 }); + + await waitForRoute(DOMAIN); + }); + + test('matched path receives the configured 301 redirect', async () => { + const res = await httpGet(DOMAIN, '/.well-known/carddav'); + expect(res.status).toBe(301); + }); + + test('301 redirect Location header points to the configured destination', async () => { + const res = await httpGet(DOMAIN, '/.well-known/carddav'); + const location = res.headers['location']; + const locationStr = Array.isArray(location) ? location[0] : (location ?? ''); + expect(locationStr).toBe('/remote.php/dav/'); + }); + + test('second matched path receives the configured 302 redirect', async () => { + const res = await httpGet(DOMAIN, '/.well-known/caldav'); + expect(res.status).toBe(302); + }); + + test('302 redirect Location header points to the configured destination', async () => { + const res = await httpGet(DOMAIN, '/.well-known/caldav'); + const location = res.headers['location']; + const locationStr = Array.isArray(location) ? location[0] : (location ?? ''); + expect(locationStr).toBe('/remote.php/dav/'); + }); + + test('unmatched path is proxied normally to the upstream', async () => { + const res = await httpGet(DOMAIN, '/some/other/path'); + expect(res.status).toBe(200); + expect(res.body).toContain('echo-ok'); + }); +}); diff --git a/tests/integration/proxy-hosts.test.ts b/tests/integration/proxy-hosts.test.ts index 5f4ccd02..7572f275 100644 --- a/tests/integration/proxy-hosts.test.ts +++ b/tests/integration/proxy-hosts.test.ts @@ -80,4 +80,58 @@ describe('proxy-hosts integration', () => { expect(row!.hstsEnabled).toBe(false); expect(row!.allowWebsocket).toBe(false); }); + + it('stores and retrieves redirect rules via meta JSON', async () => { + const redirects = [ + { from: '/.well-known/carddav', to: '/remote.php/dav/', status: 301 }, + { from: '/.well-known/caldav', to: '/remote.php/dav/', status: 301 }, + ]; + const host = await insertProxyHost({ + name: 'nextcloud', + domains: JSON.stringify(['nextcloud.example.com']), + upstreams: JSON.stringify(['192.168.1.154:11000']), + meta: JSON.stringify({ redirects }), + }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const meta = JSON.parse(row!.meta ?? '{}'); + expect(meta.redirects).toHaveLength(2); + expect(meta.redirects[0]).toMatchObject({ + from: '/.well-known/carddav', + to: '/remote.php/dav/', + status: 301, + }); + }); + + it('stores and retrieves path prefix rewrite via meta JSON', async () => { + const host = await insertProxyHost({ + name: 'recipes', + domains: JSON.stringify(['recipes.example.com']), + upstreams: JSON.stringify(['192.168.1.150:8080']), + meta: JSON.stringify({ rewrite: { path_prefix: '/recipes' } }), + }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + const meta = JSON.parse(row!.meta ?? '{}'); + expect(meta.rewrite?.path_prefix).toBe('/recipes'); + }); + + it('filters out invalid redirect rules on parse', async () => { + const redirects = [ + { from: '', to: '/valid', status: 301 }, // missing from — invalid + { from: '/valid', to: '', status: 301 }, // missing to — invalid + { from: '/ok', to: '/dest', status: 999 }, // bad status — invalid + { from: '/good', to: '/dest', status: 302 }, // valid + ]; + const host = await insertProxyHost({ + name: 'test-filter', + meta: JSON.stringify({ redirects }), + }); + const row = await db.query.proxyHosts.findFirst({ where: (t, { eq }) => eq(t.id, host.id) }); + // Simulate parseMeta sanitization: only valid rules have non-empty from/to and valid status + const meta = JSON.parse(row!.meta ?? '{}'); + const valid = (meta.redirects as typeof redirects).filter( + (r) => r.from.trim() && r.to.trim() && [301, 302, 307, 308].includes(r.status) + ); + expect(valid).toHaveLength(1); + expect(valid[0].from).toBe('/good'); + }); });