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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-28 07:56:24 +02:00
parent 3e72ddde78
commit ec3c768262
2 changed files with 157 additions and 2 deletions

View File

@@ -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 B1B8 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<void> {
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 <token>.
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();
}
});

View File

@@ -44,7 +44,7 @@ test.describe('B7 profile + API keys', () => {
test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => { test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => {
const name = `e2e-mint-${Date.now()}`; const name = `e2e-mint-${Date.now()}`;
await openMintForm(page); 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 // Pick a non-instance scope so we don't need to worry about
// mutual exclusion here. The scope-chip is a <label> wrapping // mutual exclusion here. The scope-chip is a <label> wrapping
// the checkbox — clicking the label toggles it. // the checkbox — clicking the label toggles it.
@@ -132,7 +132,7 @@ test.describe('B7 profile adversarial', () => {
const name = `e2e-copy-${Date.now()}`; const name = `e2e-copy-${Date.now()}`;
await openMintForm(page); await openMintForm(page);
await page.getByLabel('Name').fill(name); await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
await page.locator('label.scope-chip', { hasText: 'script:read' }).click(); await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click(); await page.getByRole('button', { name: /^Mint key$/ }).click();