Files
caddy-proxy-manager/tests/e2e/functional/redirects-advanced.spec.ts
fuomag9 4b5323a7bf feat: add structured redirects and path prefix rewrite for proxy hosts
Adds two new UI-configurable Caddy patterns that previously required raw JSON:
- Per-path redirect rules (from/to/status) emitted as a subroute handler before
  auth so .well-known paths work without login; supports full URLs, cross-domain
  targets, and wildcard path patterns (e.g. /.well-known/*)
- Path prefix rewrite that prepends a segment to every request before proxying
  (e.g. /recipes → upstream sees /recipes/original/path)

Config is stored in the existing meta JSON column (no schema migration). Includes
integration tests for meta serialization and E2E functional tests against a real
Caddy instance covering relative/absolute destinations, all 3xx status codes, and
various wildcard combinations. Adds traefik/whoami to the test stack to verify
rewritten paths actually reach the upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 17:53:33 +01:00

193 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<ReturnType<typeof httpGet>>): 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');
});
});