refractor code to allow more tests

This commit is contained in:
fuomag9
2026-03-07 16:53:36 +01:00
parent f85c425ac1
commit e5ba3e1ed9
17 changed files with 1833 additions and 323 deletions

View File

@@ -0,0 +1,68 @@
/**
* Functional tests: HTTP Basic Auth via access lists.
*
* Creates an access list with a test user, attaches it to a proxy host,
* and verifies Caddy enforces authentication before forwarding requests
* to the upstream echo server.
*
* Domain: func-auth.test
*/
import { test, expect } from '@playwright/test';
import { createProxyHost, createAccessList } from '../../helpers/proxy-api';
import { httpGet, waitForRoute } from '../../helpers/http';
const DOMAIN = 'func-auth.test';
const LIST_NAME = 'Functional Auth List';
const TEST_USER = { username: 'testuser', password: 'S3cur3P@ss!' };
const ECHO_BODY = 'echo-ok';
function basicAuth(username: string, password: string): string {
return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
}
test.describe.serial('Access Control (HTTP Basic Auth)', () => {
test('setup: create access list and attach to proxy host', async ({ page }) => {
await createAccessList(page, LIST_NAME, [TEST_USER]);
await createProxyHost(page, {
name: 'Functional Auth Test',
domain: DOMAIN,
upstream: 'echo-server:8080',
accessListName: LIST_NAME,
});
await waitForRoute(DOMAIN);
});
test('request without credentials returns 401', async () => {
const res = await httpGet(DOMAIN);
expect(res.status).toBe(401);
});
test('request with wrong password returns 401', async () => {
const res = await httpGet(DOMAIN, '/', {
Authorization: basicAuth(TEST_USER.username, 'wrongpassword'),
});
expect(res.status).toBe(401);
});
test('request with wrong username returns 401', async () => {
const res = await httpGet(DOMAIN, '/', {
Authorization: basicAuth('wronguser', TEST_USER.password),
});
expect(res.status).toBe(401);
});
test('request with correct credentials reaches upstream', async () => {
const res = await httpGet(DOMAIN, '/', {
Authorization: basicAuth(TEST_USER.username, TEST_USER.password),
});
expect(res.status).toBe(200);
expect(res.body).toContain(ECHO_BODY);
});
test('401 response includes WWW-Authenticate header', async () => {
const res = await httpGet(DOMAIN);
expect(res.status).toBe(401);
const wwwAuth = res.headers['www-authenticate'];
expect(String(Array.isArray(wwwAuth) ? wwwAuth[0] : wwwAuth)).toMatch(/basic/i);
});
});

View File

@@ -0,0 +1,60 @@
/**
* Functional tests: round-robin load balancing across multiple upstreams.
*
* Creates a proxy host with two echo servers as upstreams. Each server
* returns a distinct body so tests can verify that traffic is distributed
* across both backends.
*
* Domain: func-lb.test
*/
import { test, expect } from '@playwright/test';
import { createProxyHost } from '../../helpers/proxy-api';
import { httpGet, waitForRoute } from '../../helpers/http';
const DOMAIN = 'func-lb.test';
test.describe.serial('Load Balancing (multiple upstreams)', () => {
test('setup: create proxy host with two upstreams', async ({ page }) => {
await createProxyHost(page, {
name: 'Functional LB Test',
domain: DOMAIN,
// Two upstreams separated by newline — both will be round-robined by Caddy.
// echo-server returns "echo-ok", echo-server-2 returns "echo-server-2".
upstream: 'echo-server:8080\necho-server-2:8080',
});
await waitForRoute(DOMAIN);
});
test('all requests return 200', async () => {
for (let i = 0; i < 5; i++) {
const res = await httpGet(DOMAIN, '/');
expect(res.status).toBe(200);
}
});
test('both upstreams are reached over multiple requests', async () => {
const bodies = new Set<string>();
// Send enough requests that both backends should be hit via round-robin.
for (let i = 0; i < 20; i++) {
const res = await httpGet(DOMAIN, '/');
if (res.body.includes('echo-ok') || res.body.includes('echo-server-2')) {
bodies.add(res.body.trim());
}
}
// Both distinct responses must appear
expect(bodies.size).toBeGreaterThanOrEqual(2);
const arr = Array.from(bodies);
expect(arr.some((b) => b.includes('echo-ok'))).toBe(true);
expect(arr.some((b) => b.includes('echo-server-2'))).toBe(true);
});
test('different paths all return 200', async () => {
const paths = ['/', '/api/test', '/some/deep/path', '/health'];
for (const path of paths) {
const res = await httpGet(DOMAIN, path);
expect(res.status).toBe(200);
}
});
});

View File

@@ -0,0 +1,66 @@
/**
* Functional tests: basic reverse-proxy routing.
*
* Creates a real proxy host pointing at the echo-server container,
* then sends HTTP requests directly to Caddy and asserts the response
* comes from the upstream.
*
* Domain: func-proxy.test (no DNS resolution needed — requests go to
* 127.0.0.1:80 with a custom Host header, which Caddy routes by hostname).
*/
import { test, expect } from '@playwright/test';
import { createProxyHost } from '../../helpers/proxy-api';
import { httpGet, waitForRoute } from '../../helpers/http';
const DOMAIN = 'func-proxy.test';
const ECHO_BODY = 'echo-ok';
test.describe.serial('Proxy Routing', () => {
test('setup: create proxy host pointing at echo server', async ({ page }) => {
await createProxyHost(page, {
name: 'Functional Proxy Test',
domain: DOMAIN,
upstream: 'echo-server:8080',
});
await waitForRoute(DOMAIN);
});
test('routes HTTP requests to the upstream echo server', async () => {
const res = await httpGet(DOMAIN);
expect(res.status).toBe(200);
expect(res.body).toContain(ECHO_BODY);
});
test('proxies arbitrary paths to the upstream', async () => {
const res = await httpGet(DOMAIN, '/some/path?q=hello');
expect(res.status).toBe(200);
expect(res.body).toContain(ECHO_BODY);
});
test('unknown domain is not proxied to the echo server', async () => {
// Caddy may return 404 or redirect (308 HTTP→HTTPS) for unmatched routes —
// either way the request must not reach the echo upstream.
const res = await httpGet('no-such-route.test');
expect(res.status).not.toBe(200);
expect(res.body).not.toContain(ECHO_BODY);
});
test('disabled proxy host stops routing traffic', async ({ page }) => {
await page.goto('/proxy-hosts');
const row = page.locator('tr', { hasText: 'Functional Proxy Test' });
// Toggle the enabled switch (first checkbox inside the row)
await row.locator('input[type="checkbox"]').first().click({ force: true });
// Give Caddy time to reload config
await page.waitForTimeout(3_000);
const res = await httpGet(DOMAIN);
// Disabled host is removed from the route; Caddy may return 404 or
// redirect (308 HTTP→HTTPS) — either way the echo server is not reached.
expect(res.status).not.toBe(200);
expect(res.body).not.toContain(ECHO_BODY);
// Re-enable
await row.locator('input[type="checkbox"]').first().click({ force: true });
await page.waitForTimeout(2_000);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Functional tests: HTTP→HTTPS redirect when ssl_forced is enabled.
*
* Creates a proxy host with ssl_forced=true (the default when the form
* field is present without the ssl_forced_present bypass) and verifies
* that plain HTTP requests receive a 308 permanent redirect to HTTPS.
*
* Domain: func-ssl.test
*/
import { test, expect } from '@playwright/test';
import { createProxyHost } from '../../helpers/proxy-api';
import { httpGet, waitForRoute } from '../../helpers/http';
import { injectFormFields } from '../../helpers/http';
const DOMAIN = 'func-ssl.test';
test.describe.serial('SSL Redirect (ssl_forced)', () => {
test('setup: create proxy host with ssl_forced=true', async ({ page }) => {
// Navigate to proxy-hosts and open the create dialog manually so we can
// inject ssl_forced=true without the ssl_forced_present bypass.
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 SSL Redirect Test');
await page.getByLabel(/domains/i).fill(DOMAIN);
await page.getByPlaceholder('10.0.0.5:8080').fill('echo-server:8080');
// Inject ssl_forced=true (default form behavior — no override)
await injectFormFields(page, {
ssl_forced_present: 'on',
ssl_forced: 'on', // checkbox checked → ssl_forced = true
});
await page.getByRole('button', { name: /^create$/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Functional SSL Redirect Test')).toBeVisible({ timeout: 10_000 });
await waitForRoute(DOMAIN);
});
test('HTTP request receives 308 redirect to HTTPS', async () => {
const res = await httpGet(DOMAIN, '/');
// Caddy redirects HTTP→HTTPS when ssl_forced=true
expect(res.status).toBe(308);
});
test('redirect Location header points to HTTPS', async () => {
const res = await httpGet(DOMAIN, '/');
expect(res.status).toBe(308);
const location = res.headers['location'];
const locationStr = Array.isArray(location) ? location[0] : (location ?? '');
expect(locationStr).toMatch(/^https:\/\//);
expect(locationStr).toContain(DOMAIN);
});
test('redirect preserves the request path', async () => {
const res = await httpGet(DOMAIN, '/some/path');
expect(res.status).toBe(308);
const location = res.headers['location'];
const locationStr = Array.isArray(location) ? location[0] : (location ?? '');
expect(locationStr).toContain('/some/path');
});
});

View File

@@ -0,0 +1,68 @@
/**
* Functional tests: WAF (Web Application Firewall) blocking.
*
* Creates a proxy host with per-host WAF enabled (OWASP CRS, blocking mode)
* and verifies Caddy/Coraza blocks known attack payloads while passing
* legitimate traffic through to the echo server.
*
* Domain: func-waf.test
*/
import { test, expect } from '@playwright/test';
import { createProxyHost } from '../../helpers/proxy-api';
import { httpGet, waitForRoute } from '../../helpers/http';
const DOMAIN = 'func-waf.test';
const ECHO_BODY = 'echo-ok';
test.describe.serial('WAF Blocking', () => {
test('setup: create proxy host with WAF + OWASP CRS enabled', async ({ page }) => {
await createProxyHost(page, {
name: 'Functional WAF Test',
domain: DOMAIN,
upstream: 'echo-server:8080',
enableWaf: true,
});
await waitForRoute(DOMAIN);
});
test('legitimate request passes through WAF', async () => {
const res = await httpGet(DOMAIN, '/');
expect(res.status).toBe(200);
expect(res.body).toContain(ECHO_BODY);
});
test('legitimate query string passes through WAF', async () => {
const res = await httpGet(DOMAIN, '/search?q=hello+world&page=2');
expect(res.status).toBe(200);
expect(res.body).toContain(ECHO_BODY);
});
test('SQL injection UNION SELECT is blocked (CRS rule 942xxx)', async () => {
// URL-encoded: ?id=1' UNION SELECT 1,2,3--
const res = await httpGet(DOMAIN, "/search?id=1'%20UNION%20SELECT%201%2C2%2C3--");
expect(res.status).toBe(403);
});
test("SQL injection OR '1'='1 is blocked", async () => {
// URL-encoded: ?id=1' OR '1'='1
const res = await httpGet(DOMAIN, "/item?id=1'%20OR%20'1'%3D'1");
expect(res.status).toBe(403);
});
test('XSS <script> tag is blocked (CRS rule 941xxx)', async () => {
// URL-encoded: ?q=<script>alert(1)</script>
const res = await httpGet(DOMAIN, '/page?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E');
expect(res.status).toBe(403);
});
test('XSS javascript: URI is blocked', async () => {
// URL-encoded: ?url=javascript:alert(document.cookie)
const res = await httpGet(DOMAIN, '/redir?url=javascript%3Aalert(document.cookie)');
expect(res.status).toBe(403);
});
test('path traversal ../../etc/passwd is blocked (CRS rule 930xxx)', async () => {
const res = await httpGet(DOMAIN, '/files/..%2F..%2F..%2Fetc%2Fpasswd');
expect(res.status).toBe(403);
});
});