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>
This commit is contained in:
189
dashboard/tests/e2e/routing/routing.spec.ts
Normal file
189
dashboard/tests/e2e/routing/routing.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user