Files
caddy-proxy-manager/tests/e2e/functional/redirects-advanced.spec.ts
akanealw 99819b70ff
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
added caddy-proxy-manager for testing
2026-04-21 22:49:08 +00:00

193 lines
8.6 KiB
TypeScript
Executable File
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.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');
});
});