/** * 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.getByRole('table').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'); }); });