Files
caddy-proxy-manager/tests/e2e/functional/redirects-advanced.spec.ts
fuomag9 9c60d11c2c feat: improve UI contrast, dark mode, dialog sizing, color coherence, and add table sorting
- Fix dialog scrollability (flex layout + max-h-[90dvh]) and increase L4 dialog to lg width
- Add styled enable card to L4 dialog matching proxy host pattern
- Unify section colors across proxy host and L4 dialogs (cyan=LB, emerald=DNS, violet=upstream DNS, rose=geo, amber=mTLS)
- Improve light mode contrast: muted-foreground oklch 0.552→0.502, remove opacity modifiers on secondary text
- Improve dark mode: boost muted-foreground to 0.85, increase border opacity 10%→16%, input 15%→20%
- Add bg-card to DataTable wrapper and bg-muted/40 to table headers for surface hierarchy
- Add semantic badge variants (success, warning, info, muted) and StatusChip dark mode fix
- Add server-side sortable columns to Proxy Hosts and L4 Proxy Hosts (name, upstream, status, protocol, listen)
- Add sortKey to DataTable Column type with clickable sort headers (ArrowUp/Down indicators, URL param driven)
- Fix E2E test selectors for shadcn UI (label associations, combobox roles, dropdown menus, mobile drawer)
- Add htmlFor/id to proxy host form fields and aria-labels to select triggers for accessibility
- Add sorting E2E tests for both proxy host pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:17:56 +01:00

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