diff --git a/dashboard/tests/e2e/apps/apps.spec.ts b/dashboard/tests/e2e/apps/apps.spec.ts new file mode 100644 index 0000000..d58554a --- /dev/null +++ b/dashboard/tests/e2e/apps/apps.spec.ts @@ -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 { + 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(); + }); +});