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 { 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 { 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: '/', 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