diff --git a/dashboard/tests/e2e/routing/routing.spec.ts b/dashboard/tests/e2e/routing/routing.spec.ts new file mode 100644 index 0000000..0c2457f --- /dev/null +++ b/dashboard/tests/e2e/routing/routing.spec.ts @@ -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 { + 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