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 { 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 { 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 = ''; 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(); }); });