Six tests covering /admin/profile: mint instance-wide key with the reveal/ack flow, the app-binding mutual-exclusion guard (instance scopes auto-disabled), revoke via the ConfirmModal, the ?denied=users banner, plus adversarial cases (empty-name button disabled, copy-token writes the full token to the clipboard). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151 lines
5.5 KiB
TypeScript
151 lines
5.5 KiB
TypeScript
import { expect, type Page } from '@playwright/test';
|
|
import { test } from '../fixtures/ids';
|
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
|
import { adminApi } from '../fixtures/api';
|
|
|
|
// Phase B7 — Profile + API Keys (/admin/profile). Covers the
|
|
// mint/reveal/revoke flow, the app-binding mutual-exclusion guard,
|
|
// and adversarial inputs.
|
|
|
|
const cleanup = new CleanupRegistry();
|
|
test.afterEach(async () => {
|
|
await cleanup.run();
|
|
});
|
|
|
|
async function createApp(slug: string): Promise<string> {
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
|
expect(res.ok()).toBe(true);
|
|
return ((await res.json()) as { id: string }).id;
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
}
|
|
|
|
async function openMintForm(page: Page): Promise<void> {
|
|
await page.goto('/admin/profile');
|
|
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
|
|
}
|
|
|
|
async function registerKeyCleanupByName(name: string): Promise<void> {
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.get('/api/v1/admin/api-keys');
|
|
const all = (await res.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();
|
|
}
|
|
}
|
|
|
|
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);
|
|
// 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.
|
|
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();
|
|
await expect(reveal.locator('code.token')).toContainText(/\S{16,}/);
|
|
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
|
|
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
|
|
|
await registerKeyCleanupByName(name);
|
|
await expect(page.getByText(name)).toBeVisible();
|
|
});
|
|
|
|
test('binding to an app disables instance scopes', async ({ page, uniqueSlug }) => {
|
|
const slug = uniqueSlug('keyapp');
|
|
const appId = await createApp(slug);
|
|
cleanup.app(slug);
|
|
|
|
await openMintForm(page);
|
|
|
|
// Default binding is Instance-wide — instance scopes are
|
|
// enabled.
|
|
const instChip = page.locator('label.scope-chip', { hasText: 'instance:admin' });
|
|
await expect(instChip).not.toHaveClass(/disabled/);
|
|
|
|
// Switch binding to the app. The chip becomes disabled.
|
|
await page.getByLabel(/Binding/i).selectOption(appId);
|
|
await expect(instChip).toHaveClass(/disabled/);
|
|
});
|
|
|
|
test('revoke key removes it from the list', async ({ page }) => {
|
|
const name = `e2e-revoke-${Date.now()}`;
|
|
// Seed a key via API so the test focuses on the revoke UI.
|
|
const api = await adminApi();
|
|
try {
|
|
const res = await api.post('/api/v1/admin/api-keys', {
|
|
data: { name, scopes: ['script:read'] }
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = (await res.json()) as { id: string };
|
|
cleanup.apiKey(body.id);
|
|
} finally {
|
|
await api.dispose();
|
|
}
|
|
|
|
await page.goto('/admin/profile');
|
|
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
|
|
await expect(revokeBtn).toBeVisible();
|
|
await revokeBtn.click();
|
|
|
|
const dialog = page.getByRole('dialog');
|
|
await dialog.getByRole('button', { name: /^Revoke$/ }).click();
|
|
// Assert the row's revoke button is gone (the flash banner
|
|
// also mentions the name, so a plain getByText would still
|
|
// match — anchor on the row-scoped button instead).
|
|
await expect(revokeBtn).toHaveCount(0);
|
|
});
|
|
|
|
test('denied=users banner shows when arriving from the users redirect', async ({ page }) => {
|
|
await page.goto('/admin/profile?denied=users');
|
|
await expect(page.getByText(/don.?t have access to the Users page/i)).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('B7 profile adversarial', () => {
|
|
test('empty name keeps the mint button disabled', async ({ page }) => {
|
|
await openMintForm(page);
|
|
// Trying to click would HTML5-validate; instead verify the
|
|
// button is disabled while name is empty.
|
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
|
await expect(page.getByRole('button', { name: /^Mint key$/ })).toBeDisabled();
|
|
});
|
|
|
|
test('copy-token button copies the full token, not a truncated form', async ({
|
|
page,
|
|
context
|
|
}) => {
|
|
// Permission must be granted explicitly; chromium will throw
|
|
// otherwise when calling navigator.clipboard.readText().
|
|
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
|
|
|
const name = `e2e-copy-${Date.now()}`;
|
|
await openMintForm(page);
|
|
await page.getByLabel('Name').fill(name);
|
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
|
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
|
|
|
const reveal = page.locator('.reveal');
|
|
const tokenInDom = await reveal.locator('code.token').textContent();
|
|
expect(tokenInDom).toBeTruthy();
|
|
await reveal.getByRole('button', { name: /^Copy$/ }).click();
|
|
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
|
expect(copied).toBe(tokenInDom);
|
|
|
|
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
|
await registerKeyCleanupByName(name);
|
|
});
|
|
});
|