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>
156 lines
6.1 KiB
TypeScript
156 lines
6.1 KiB
TypeScript
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();
|
||
}
|
||
});
|