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:
155
dashboard/tests/e2e/integration/integration.spec.ts
Normal file
155
dashboard/tests/e2e/integration/integration.spec.ts
Normal 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 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<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();
|
||||
}
|
||||
});
|
||||
@@ -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 <label> wrapping
|
||||
// the checkbox — clicking the label toggles it.
|
||||
@@ -132,7 +132,7 @@ test.describe('B7 profile adversarial', () => {
|
||||
|
||||
const name = `e2e-copy-${Date.now()}`;
|
||||
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.getByRole('button', { name: /^Mint key$/ }).click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user