Eight tests covering the login form, layout-level redirects, logout, and the obvious adversarial inputs (XSS in username, empty submit, password field type, leaked tokens). All targeted at /admin/login and the bounce-back behaviors implemented in +layout.svelte. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
4.5 KiB
TypeScript
112 lines
4.5 KiB
TypeScript
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 = '<script>window.__xss = true;</script><img src=x onerror=alert(1)>';
|
|
|
|
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$/);
|
|
});
|
|
});
|