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>
193 lines
8.5 KiB
TypeScript
193 lines
8.5 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|
||
});
|