From ec3c7682620ba7085296387dc04184608a1fcf64 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 28 May 2026 07:56:24 +0200 Subject: [PATCH] test(dashboard): add full-stack integration specs Two scenarios that span the dashboard UI and the data/control plane end-to-end: - App + domain claim + script + route all created via the dashboard, then the script is invoked through the public URL with the matching Host header. Verifies the dashboard actions actually reach the orchestrator's route trie. - API key minted via the dashboard, then used as a bearer token against /api/v1/admin/* (the CLI surface). Confirms the scope is enforced (script:read passes /scripts, 403s /admins) and that revoking via the dashboard immediately invalidates the token. Also: the B7 copy-token test selected the mint-form Name input via getByLabel('Name'), which became ambiguous once the integration test created an app and the Binding dropdown was no longer empty. Switched both B7 mint flows to placeholder-based selectors. Suite: 57/57 passing in ~18s. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/e2e/integration/integration.spec.ts | 155 ++++++++++++++++++ dashboard/tests/e2e/profile/profile.spec.ts | 4 +- 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 dashboard/tests/e2e/integration/integration.spec.ts diff --git a/dashboard/tests/e2e/integration/integration.spec.ts b/dashboard/tests/e2e/integration/integration.spec.ts new file mode 100644 index 0000000..de1edfc --- /dev/null +++ b/dashboard/tests/e2e/integration/integration.spec.ts @@ -0,0 +1,155 @@ +import { expect, request, type Page } from '@playwright/test'; +import { test } from '../fixtures/ids'; +import { CleanupRegistry } from '../fixtures/cleanup'; +import { adminApi } from '../fixtures/api'; + +// Full-stack integration scenarios. Unlike the per-page B1–B8 specs, +// these drive a complete user journey across multiple pages and then +// verify the data plane / API surface behaves the way the dashboard +// promised it would. + +const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080'; + +const cleanup = new CleanupRegistry(); +test.afterEach(async () => { + await cleanup.run(); +}); + +async function fillCodeMirror(page: Page, locator: string, text: string): Promise { + const cm = page.locator(locator).first(); + await cm.click(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.press('Delete'); + await page.keyboard.type(text); +} + +test('end-to-end: app + domain + script + route via dashboard → invoke via public URL', async ({ + page, + uniqueSlug +}) => { + const slug = uniqueSlug('public'); + const domain = `${slug}.local`; + const routePath = `/${slug}/hello`; + const scriptName = `${slug}-hello`; + const scriptSource = `return #{ statusCode: 200, body: #{ source: "public", slug: "${slug}" } };`; + + // 1. Create the app from the apps list. + await page.goto('/admin/apps'); + await page.getByRole('button', { name: 'New app' }).click(); + await page.getByLabel('Name').fill(slug); + const slugInput = page.getByLabel('Slug'); + await slugInput.fill(''); + await slugInput.fill(slug); + await page.getByRole('button', { name: 'Create app' }).click(); + cleanup.app(slug); + await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible(); + + // 2. Open the app and claim the domain on the Domains tab. + await page.getByRole('link', { name: new RegExp(slug) }).click(); + await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`)); + await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click(); + const domainForm = page.locator('form.create-form.inline'); + await domainForm.getByPlaceholder(/app\.example\.com/).fill(domain); + await domainForm.getByRole('button', { name: /^Add domain$/ }).click(); + await expect(page.locator('.domain-row')).toContainText(domain); + + // 3. Create the script on the Scripts tab. + await page.getByRole('button', { name: /^Scripts \(\d+\)$/ }).click(); + await page.getByRole('button', { name: /^New script$/ }).click(); + await page.getByLabel('Name').fill(scriptName); + await fillCodeMirror(page, '.cm-content', scriptSource); + await page.getByRole('button', { name: /^Create script$/ }).click(); + + // 4. Open the script and bind a route on the Routing tab. + await page.getByRole('link', { name: new RegExp(scriptName) }).click(); + await page.getByRole('button', { name: 'Routing' }).click(); + await page.getByRole('button', { name: '+ Add route' }).click(); + const routeForm = page.locator('form.route-form'); + await routeForm.getByLabel('Path', { exact: true }).fill(routePath); + await routeForm.getByLabel('Method').selectOption('GET'); + await routeForm.getByLabel(/^Host/).fill(domain); + await page.getByRole('button', { name: /^Create route$/ }).click(); + await expect(page.locator('.route-list')).toContainText(routePath); + + // 5. Invoke via the public URL, with the Host header pointing at + // the claimed domain. The dev backend listens on 127.0.0.1; the + // orchestrator resolves the app from Host, then the route. + const publicCtx = await request.newContext({ baseURL: API_BASE }); + try { + const res = await publicCtx.get(routePath, { headers: { host: domain } }); + expect(res.status()).toBe(200); + const body = (await res.json()) as { source: string; slug: string }; + expect(body.source).toBe('public'); + expect(body.slug).toBe(slug); + } finally { + await publicCtx.dispose(); + } +}); + +test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({ + page +}) => { + const name = `e2e-cli-${Date.now()}`; + + // 1. Mint the key from /profile and capture the revealed token. + await page.goto('/admin/profile'); + await page.getByRole('button', { name: /\+ Mint API key/ }).click(); + const mintForm = page.locator('form.mint'); + await mintForm.getByPlaceholder('e.g. ci-deploy').fill(name); + // script:read is enough to read the scripts list — that's our + // "CLI verb" below. + await page.locator('label.scope-chip', { hasText: 'script:read' }).click(); + await page.getByRole('button', { name: /^Mint key$/ }).click(); + + const reveal = page.locator('.reveal'); + await expect(reveal).toBeVisible(); + const rawToken = (await reveal.locator('code.token').textContent())?.trim(); + expect(rawToken).toBeTruthy(); + await reveal.getByRole('checkbox', { name: /saved this token/i }).check(); + await reveal.getByRole('button', { name: /^Done$/ }).click(); + + // 2. Act like a CLI: call the API directly with Bearer . + const cli = await request.newContext({ + baseURL: API_BASE, + extraHTTPHeaders: { authorization: `Bearer ${rawToken}` } + }); + try { + const ok = await cli.get('/api/v1/admin/scripts'); + expect(ok.status()).toBe(200); + const body = (await ok.json()) as unknown; + expect(Array.isArray(body)).toBe(true); + + // Sanity: a route the scope doesn't cover must reject. + // `script:read` cannot list instance admins (that's + // instance:admin territory). + const denied = await cli.get('/api/v1/admin/admins'); + expect(denied.status()).toBe(403); + + // 3. Revoke via the dashboard. + await page.reload(); + const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` }); + await expect(revokeBtn).toBeVisible(); + await revokeBtn.click(); + await page.getByRole('dialog').getByRole('button', { name: /^Revoke$/ }).click(); + await expect(revokeBtn).toHaveCount(0); + + // 4. Same CLI call must now fail auth. + const afterRevoke = await cli.get('/api/v1/admin/scripts'); + expect(afterRevoke.status()).toBe(401); + } finally { + await cli.dispose(); + } + + // Belt-and-braces cleanup: if the UI revoke missed, drop via API. + const api = await adminApi(); + try { + const list = await api.get('/api/v1/admin/api-keys'); + if (list.ok()) { + const all = (await list.json()) as Array<{ id: string; name: string }>; + const k = all.find((x) => x.name === name); + if (k) cleanup.apiKey(k.id); + } + } finally { + await api.dispose(); + } +}); diff --git a/dashboard/tests/e2e/profile/profile.spec.ts b/dashboard/tests/e2e/profile/profile.spec.ts index 9016a2f..0e18857 100644 --- a/dashboard/tests/e2e/profile/profile.spec.ts +++ b/dashboard/tests/e2e/profile/profile.spec.ts @@ -44,7 +44,7 @@ test.describe('B7 profile + API keys', () => { test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => { const name = `e2e-mint-${Date.now()}`; await openMintForm(page); - await page.getByLabel('Name').fill(name); + await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name); // Pick a non-instance scope so we don't need to worry about // mutual exclusion here. The scope-chip is a