diff --git a/dashboard/tests/e2e/auth/auth.spec.ts b/dashboard/tests/e2e/auth/auth.spec.ts new file mode 100644 index 0000000..85443f1 --- /dev/null +++ b/dashboard/tests/e2e/auth/auth.spec.ts @@ -0,0 +1,111 @@ +import { expect, test, type Page } from '@playwright/test'; +import { loginAsAdmin, logout } from '../fixtures/auth'; + +// Phase B1 — Auth & Navigation. Every interaction with the login form +// and the layout-level redirects, plus the obvious adversarial inputs. + +const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin'; +const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin'; + +function failOnDialog(page: Page): void { + page.on('dialog', async (dialog) => { + await dialog.dismiss(); + throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`); + }); +} + +test.describe('B1 auth — unauthenticated', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('valid credentials land on the apps list', async ({ page }) => { + await loginAsAdmin(page); + await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible(); + }); + + test('wrong password shows an inline error and stays on /login', async ({ page }) => { + await page.goto('/admin/login'); + await page.getByLabel('Username').fill(VALID_USERNAME); + await page.getByLabel('Password').fill('definitely-not-the-password'); + await page.getByRole('button', { name: /sign in/i }).click(); + + const error = page.locator('.error'); + await expect(error).toBeVisible(); + await expect(error).not.toHaveText(''); + await expect(page).toHaveURL(/\/admin\/login$/); + // localStorage must remain empty — a failed login should not + // leak a session token. + const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token')); + expect(token).toBeNull(); + }); + + test('empty submit is blocked by the browser and does not navigate', async ({ page }) => { + await page.goto('/admin/login'); + await page.getByRole('button', { name: /sign in/i }).click(); + // HTML5 validation prevents submission; URL is unchanged and the + // username input is reported invalid. + await expect(page).toHaveURL(/\/admin\/login$/); + const usernameInvalid = await page + .getByLabel('Username') + .evaluate((el: HTMLInputElement) => !el.validity.valid); + expect(usernameInvalid).toBe(true); + await expect(page.locator('.error')).toBeHidden(); + }); + + test('visiting an authed route redirects to /login', async ({ page }) => { + await page.goto('/admin/apps'); + await expect(page).toHaveURL(/\/admin\/login$/); + await expect(page.getByLabel('Username')).toBeVisible(); + }); + + test('password field is type=password (no plaintext echo)', async ({ page }) => { + await page.goto('/admin/login'); + await expect(page.getByLabel('Password')).toHaveAttribute('type', 'password'); + }); + + test('xss payload in username is escaped and does not execute', async ({ page }) => { + failOnDialog(page); + const payload = ''; + + await page.goto('/admin/login'); + await page.getByLabel('Username').fill(payload); + await page.getByLabel('Password').fill('whatever'); + await page.getByRole('button', { name: /sign in/i }).click(); + + // Whatever the API does with that input, the page must remain + // safe: no script tag injected into the DOM, no global side + // effect, and a visible error (since the credentials don't + // match any user). + await expect(page.locator('.error')).toBeVisible(); + const xssRan = await page.evaluate( + () => (window as unknown as { __xss?: boolean }).__xss === true + ); + expect(xssRan).toBe(false); + const injectedScript = await page.locator('script:has-text("__xss")').count(); + expect(injectedScript).toBe(0); + // The form must still be functional after the rejected attempt. + await page.getByLabel('Username').fill(''); + await page.getByLabel('Username').fill(VALID_USERNAME); + await page.getByLabel('Password').fill(''); + await page.getByLabel('Password').fill(VALID_PASSWORD); + await page.getByRole('button', { name: /sign in/i }).click(); + await expect(page).toHaveURL(/\/admin\/apps$/); + }); +}); + +test.describe('B1 auth — authenticated', () => { + test('visiting /login while signed in bounces to /apps', async ({ page }) => { + await page.goto('/admin/login'); + await expect(page).toHaveURL(/\/admin\/apps$/); + }); + + test('logout clears the session and lands on /login', async ({ page }) => { + await page.goto('/admin/apps'); + await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible(); + await logout(page); + const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token')); + expect(token).toBeNull(); + // And the authed area is now gated again. + await page.goto('/admin/apps'); + await expect(page).toHaveURL(/\/admin\/login$/); + }); +});