test(dashboard): add e2e apps lifecycle spec (B2)
Seven tests covering app CRUD via the dashboard: create with slug auto-derive, settings rename, delete with phrase-confirmation modal, historical-slug takeover via the create form, plus adversarial inputs (slug normalization, XSS in name/description, oversized name). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
226
dashboard/tests/e2e/apps/apps.spec.ts
Normal file
226
dashboard/tests/e2e/apps/apps.spec.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
|
||||||
|
// historical-slug takeover flow and adversarial inputs.
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
function failOnDialog(page: Page): void {
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.dismiss();
|
||||||
|
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateForm(page: Page): Promise<void> {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await page.getByRole('button', { name: 'New app' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApp(
|
||||||
|
page: Page,
|
||||||
|
opts: { name: string; slug: string; description?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(opts.name);
|
||||||
|
// Clear the auto-derived slug and type the test-controlled one so
|
||||||
|
// we know exactly which slug we'll register for cleanup.
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await slugInput.fill('');
|
||||||
|
await slugInput.fill(opts.slug);
|
||||||
|
if (opts.description !== undefined) {
|
||||||
|
await page.getByLabel('Description').fill(opts.description);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B2 apps lifecycle', () => {
|
||||||
|
test('create app: slug auto-derives from name, app appears in list', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('lifecycle');
|
||||||
|
const displayName = slug.replace(/-/g, ' ');
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(displayName);
|
||||||
|
// Slug auto-derives — the input value is set, no extra typing.
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await expect(slugInput).toHaveValue(slug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(displayName) })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit name + description in settings persists across reload', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
await createApp(page, { name: slug, slug });
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
|
||||||
|
const newName = `${slug} renamed`;
|
||||||
|
const newDesc = 'updated description';
|
||||||
|
await page.getByLabel('Name').fill(newName);
|
||||||
|
await page.getByLabel('Description').fill(newDesc);
|
||||||
|
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||||
|
// Wait for the network round-trip to settle — the busy label
|
||||||
|
// flips back to "Save changes" when done.
|
||||||
|
await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await expect(page.getByLabel('Name')).toHaveValue(newName);
|
||||||
|
await expect(page.getByLabel('Description')).toHaveValue(newDesc);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete: wrong phrase keeps button disabled, right phrase removes app', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('delete');
|
||||||
|
await createApp(page, { name: slug, slug });
|
||||||
|
cleanup.app(slug); // belt-and-braces; cleanup is best-effort
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete app' }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
const phraseInput = dialog.getByRole('textbox');
|
||||||
|
const confirmBtn = dialog.getByRole('button', { name: 'Delete app' });
|
||||||
|
await expect(confirmBtn).toBeDisabled();
|
||||||
|
|
||||||
|
await phraseInput.fill('wrong-phrase');
|
||||||
|
await expect(confirmBtn).toBeDisabled();
|
||||||
|
|
||||||
|
await phraseInput.fill(slug);
|
||||||
|
await expect(confirmBtn).toBeEnabled();
|
||||||
|
await confirmBtn.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historical slug warning surfaces; force-takeover succeeds', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const origSlug = uniqueSlug('hist');
|
||||||
|
const renamedSlug = `${origSlug}-r`;
|
||||||
|
|
||||||
|
// Historical-redirect rows are created on RENAME, not on
|
||||||
|
// delete. So: create app, rename it, original slug now lives
|
||||||
|
// in app_slug_history.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const created = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: origSlug, name: origSlug }
|
||||||
|
});
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const renamed = await api.patch(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(origSlug)}`,
|
||||||
|
{ data: { slug: renamedSlug } }
|
||||||
|
);
|
||||||
|
expect(renamed.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
cleanup.app(renamedSlug); // the renamed app still exists
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(origSlug);
|
||||||
|
await page.getByLabel('Slug').fill('');
|
||||||
|
await page.getByLabel('Slug').fill(origSlug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.warning')).toBeVisible();
|
||||||
|
await expect(page.locator('.warning')).toContainText(/previously redirected/i);
|
||||||
|
await page.getByRole('button', { name: /claim slug anyway/i }).click();
|
||||||
|
cleanup.app(origSlug); // the takeover created a new app
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(origSlug) })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B2 apps adversarial', () => {
|
||||||
|
test('slug with uppercase + spaces is normalized in-place', async ({ page, uniqueSlug }) => {
|
||||||
|
const base = uniqueSlug('norm');
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(base);
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await slugInput.fill('');
|
||||||
|
// Simulate the user typing/pasting an invalid slug. The
|
||||||
|
// oninput handler runs slugify() and rewrites the input value.
|
||||||
|
await slugInput.fill(` Hello WORLD ${base}!`);
|
||||||
|
await expect(slugInput).toHaveValue(`hello-world-${base}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss in name and description renders as text everywhere', async ({ page, uniqueSlug }) => {
|
||||||
|
failOnDialog(page);
|
||||||
|
const slug = uniqueSlug('xss');
|
||||||
|
const payload = '<img src=x onerror=alert(1)><script>window.__xss=true;</script>';
|
||||||
|
|
||||||
|
await createApp(page, { name: payload, slug, description: payload });
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
// List page — the link's accessible name contains the literal
|
||||||
|
// payload text, not the parsed HTML.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp('img src=x') })).toBeVisible();
|
||||||
|
|
||||||
|
// Detail page — open it; payload renders in the breadcrumb /
|
||||||
|
// header as text only.
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
const xssRan = await page.evaluate(
|
||||||
|
() => (window as unknown as { __xss?: boolean }).__xss === true
|
||||||
|
);
|
||||||
|
expect(xssRan).toBe(false);
|
||||||
|
expect(await page.locator('script:has-text("__xss")').count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('very long name does not crash the dashboard', async ({ page, uniqueSlug }) => {
|
||||||
|
// The backend currently has no name length cap; the dashboard
|
||||||
|
// just needs to keep rendering when handed an unusually long
|
||||||
|
// value. Guards against layout / locator regressions when a
|
||||||
|
// future test or user creates an oversized app.
|
||||||
|
const slug = uniqueSlug('long');
|
||||||
|
const longName = 'A'.repeat(10_000);
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(longName);
|
||||||
|
await page.getByLabel('Slug').fill('');
|
||||||
|
await page.getByLabel('Slug').fill(slug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
|
||||||
|
const errorVisible = await page
|
||||||
|
.locator('.create-form .error')
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (errorVisible) {
|
||||||
|
// Server rejected — fine, no cleanup needed.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server accepted — confirm the dashboard still renders and is
|
||||||
|
// navigable. Detail page must load too.
|
||||||
|
cleanup.app(slug);
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user