Files
PiCloud/dashboard/tests/e2e/routing/routing.spec.ts
MechaCat02 f9d9ed8cb4 test(dashboard): add e2e routing spec (B4)
Seven tests covering the Routing tab inside the script editor: add
+ list + remove (handling the window.confirm dialog), match-preview
round trip, path-kind mismatch warning, unclaimed-host warning,
duplicate-route 409, plus reserved-prefix rejection and a path-XSS
adversarial that checks no script tag escapes into the route list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:18:01 +02:00

190 lines
7.0 KiB
TypeScript

import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B4 — Routing tab in the script editor. Add / remove / match
// preview + validation paths (host check, path-kind mismatch, reserved
// prefix, duplicate conflict, adversarial paths).
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function makeAppWithScript(slug: string): Promise<{ appId: string; scriptId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug, name: slug }
});
expect(appRes.ok()).toBe(true);
const appBody = (await appRes.json()) as { id: string };
const scriptRes = await api.post('/api/v1/admin/scripts', {
data: { app_id: appBody.id, name: 'route-target', source: HELLO_RHAI }
});
expect(scriptRes.ok()).toBe(true);
const scriptBody = (await scriptRes.json()) as { id: string };
return { appId: appBody.id, scriptId: scriptBody.id };
} finally {
await api.dispose();
}
}
async function gotoRoutingTab(page: Page, scriptId: string): Promise<void> {
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: 'Routing' }).click();
}
async function addRoute(
page: Page,
opts: { path: string; pathKind?: 'exact' | 'param' | 'prefix'; method?: string; host?: string }
): Promise<void> {
await page.getByRole('button', { name: '+ Add route' }).click();
const form = page.locator('form.route-form');
await form.getByLabel('Path', { exact: true }).fill(opts.path);
if (opts.pathKind) {
await form.getByLabel('Path kind').selectOption(opts.pathKind);
}
if (opts.method !== undefined) {
await form.getByLabel('Method').selectOption(opts.method);
}
if (opts.host !== undefined) {
await form.getByLabel(/^Host/).fill(opts.host);
}
}
test.describe('B4 routing', () => {
test('add route appears in list and matches in the preview', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('addr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/greet', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/greet');
// Match preview confirms the route resolves.
await page.getByLabel('URL').fill('http://localhost/greet');
await page.locator('.actions').getByRole('button', { name: 'Match' }).click();
await expect(page.locator('pre.preview')).toContainText('script_id');
});
test('remove route updates the list', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('remr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/transient', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/transient');
// removeRoute() uses window.confirm — accept it.
page.once('dialog', (d) => void d.accept());
await page.locator('.route-list').getByRole('button', { name: 'remove' }).click();
await expect(page.locator('.route-list')).toHaveCount(0);
await expect(page.getByText(/no routes yet/i)).toBeVisible();
});
test('duplicate route surfaces a 409 conflict error inline', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('dupr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/twice', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/twice');
// Same path + method again — must conflict.
await addRoute(page, { path: '/twice', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-form .error.inline')).toBeVisible();
});
test('path-kind mismatch warns inline when /:name is set to exact', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('mism');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await page.getByRole('button', { name: '+ Add route' }).click();
await page.getByLabel('Path', { exact: true }).fill('/users/:id');
// Override to a wrong kind — auto-detect would have picked
// `param`; selecting `exact` should fire the warning.
await page.getByLabel('Path kind').selectOption('exact');
await expect(page.locator('.route-form .warning.inline')).toBeVisible();
});
test('host validation warns when the host is not a claimed domain', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('unclaim');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await page.getByRole('button', { name: '+ Add route' }).click();
await page.getByLabel('Path', { exact: true }).fill('/x');
await page.getByLabel(/^Host/).fill('example.test-not-claimed.local');
// One of the inline warnings is the unclaimed-host explainer.
await expect(page.locator('.route-form .warning.inline').first()).toBeVisible();
});
});
test.describe('B4 routing adversarial', () => {
test('reserved prefix /api/ is rejected with a visible error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('reserv');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/api/v9/oops', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-form .error.inline')).toBeVisible();
await expect(page.locator('.route-form .error.inline')).toContainText(
/reserved|api|prefix/i
);
// Empty-state copy renders when no routes exist; the path
// itself must not appear anywhere on the routing tab.
await expect(page.getByText(/no routes yet/i)).toBeVisible();
});
test('xss payload in path stored or rejected — never executes on render', async ({
page,
uniqueSlug
}) => {
page.on('dialog', async (d) => {
await d.dismiss();
throw new Error(`Unexpected dialog: ${d.message()}`);
});
const slug = uniqueSlug('pxss');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, {
path: '/<script>alert(1)</script>',
method: 'GET'
});
await page.getByRole('button', { name: /^Create route$/ }).click();
// Either accepted (rendered as text in the list) or rejected
// (error inline). Both fine — what's NOT fine is an alert
// dialog or an injected <script> tag in the list.
const xssScripts = await page.locator('.route-list script:has-text("alert")').count();
expect(xssScripts).toBe(0);
});
});