refractor code to allow more tests
This commit is contained in:
68
tests/e2e/functional/access-control.spec.ts
Normal file
68
tests/e2e/functional/access-control.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
tests/e2e/functional/load-balancing.spec.ts
Normal file
60
tests/e2e/functional/load-balancing.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
66
tests/e2e/functional/proxy-routing.spec.ts
Normal file
66
tests/e2e/functional/proxy-routing.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
tests/e2e/functional/ssl-redirect.spec.ts
Normal file
64
tests/e2e/functional/ssl-redirect.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
tests/e2e/functional/waf-blocking.spec.ts
Normal file
68
tests/e2e/functional/waf-blocking.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user