refactor(e2e): migrate all tests to Playwright Test
This commit is contained in:
parent
174c6c001e
commit
7900f264ba
14 changed files with 398 additions and 401 deletions
16
tests/e2e/auth-guard.spec.js
Normal file
16
tests/e2e/auth-guard.spec.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Auth guard', () => {
|
||||||
|
test('unauthenticated /manage/credentials redirects to /login', async ({ page }) => {
|
||||||
|
await page.goto('/manage/credentials');
|
||||||
|
await page.waitForURL('**/login', { timeout: 5000 });
|
||||||
|
expect(page.url()).toContain('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unauthenticated /manage/credentials?setup=1 redirects to /login', async ({ page }) => {
|
||||||
|
await page.goto('/manage/credentials?setup=1');
|
||||||
|
await page.waitForURL('**/login', { timeout: 5000 });
|
||||||
|
expect(page.url()).toContain('/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
74
tests/e2e/credentials.spec.js
Normal file
74
tests/e2e/credentials.spec.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
||||||
|
|
||||||
|
test.describe('Credentials page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Log in with dedicated credentials user
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('#username', fixtures.cred_username);
|
||||||
|
await page.fill('#password', fixtures.cred_password);
|
||||||
|
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
||||||
|
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Page structure', () => {
|
||||||
|
test('title contains Credentials and Porchlight', async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle(/Credentials/);
|
||||||
|
await expect(page).toHaveTitle(/Porchlight/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('H1 says "Credentials"', async ({ page }) => {
|
||||||
|
await expect(page.locator('h1')).toHaveText('Credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('security keys heading is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('h2:has-text("Security keys")')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add security key button is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('#webauthn-register-btn')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password heading is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('h2:has-text("Password")')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password section is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('#password-section')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Password validation', () => {
|
||||||
|
test('shows mismatch error', async ({ page }) => {
|
||||||
|
await page.fill('#password', 'newpassword1');
|
||||||
|
await page.fill('#confirm', 'newpassword2');
|
||||||
|
await page.click('#password-section button[type="submit"]');
|
||||||
|
|
||||||
|
const alert = page.locator('#password-section [role="alert"]');
|
||||||
|
await expect(alert).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(alert).toContainText('do not match');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password input has minlength="8"', async ({ page }) => {
|
||||||
|
await expect(page.locator('#password')).toHaveAttribute('minlength', '8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm input has minlength="8"', async ({ page }) => {
|
||||||
|
await expect(page.locator('#confirm')).toHaveAttribute('minlength', '8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Password change', () => {
|
||||||
|
test('succeeds with matching passwords', async ({ page }) => {
|
||||||
|
await page.fill('#password', 'newpassword123');
|
||||||
|
await page.fill('#confirm', 'newpassword123');
|
||||||
|
await page.click('#password-section button[type="submit"]');
|
||||||
|
|
||||||
|
const status = page.locator('#password-section [role="status"]');
|
||||||
|
await expect(status).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(status).toContainText('Password updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
62
tests/e2e/full-flow.spec.js
Normal file
62
tests/e2e/full-flow.spec.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
||||||
|
|
||||||
|
test.describe('Full user journey', () => {
|
||||||
|
test('register via magic link, set password, logout, login', async ({ page, request }) => {
|
||||||
|
// Verify fixtures are loaded
|
||||||
|
expect(fixtures.register_token).toBeTruthy();
|
||||||
|
|
||||||
|
// ---- Step 1: Register via magic link ----
|
||||||
|
await page.goto(`/register/${fixtures.register_token}`);
|
||||||
|
|
||||||
|
// Should redirect to /manage/credentials?setup=1
|
||||||
|
await page.waitForURL('**/manage/credentials?setup=1', { timeout: 5000 });
|
||||||
|
expect(page.url()).toContain('/manage/credentials');
|
||||||
|
|
||||||
|
// Should show welcome message
|
||||||
|
const welcome = page.locator('[role="status"]');
|
||||||
|
await expect(welcome).toBeVisible();
|
||||||
|
const welcomeText = await welcome.textContent();
|
||||||
|
expect(welcomeText).toContain('Welcome');
|
||||||
|
|
||||||
|
// Page title should contain Porchlight
|
||||||
|
await expect(page).toHaveTitle(/Porchlight/);
|
||||||
|
|
||||||
|
// ---- Step 2: Set password ----
|
||||||
|
const passwordInput = page.locator('#password');
|
||||||
|
const confirmInput = page.locator('#confirm');
|
||||||
|
await expect(passwordInput).toBeVisible();
|
||||||
|
await expect(confirmInput).toBeVisible();
|
||||||
|
|
||||||
|
await passwordInput.fill('mypassword123');
|
||||||
|
await confirmInput.fill('mypassword123');
|
||||||
|
await page.click('#password-section button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for success message
|
||||||
|
const successMsg = page.locator('#password-section [role="status"]');
|
||||||
|
await expect(successMsg).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(successMsg).toContainText('Password updated');
|
||||||
|
|
||||||
|
// ---- Step 3: Logout ----
|
||||||
|
await request.post('/logout');
|
||||||
|
|
||||||
|
// Navigate to credentials — should redirect to login since we're logged out
|
||||||
|
await page.goto('/manage/credentials');
|
||||||
|
await page.waitForURL('**/login', { timeout: 5000 });
|
||||||
|
expect(page.url()).toContain('/login');
|
||||||
|
|
||||||
|
// ---- Step 4: Login with the password we just set ----
|
||||||
|
await page.fill('#username', fixtures.register_username);
|
||||||
|
await page.fill('#password', 'mypassword123');
|
||||||
|
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for redirect to credentials page
|
||||||
|
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
||||||
|
expect(page.url()).toContain('/manage/credentials');
|
||||||
|
|
||||||
|
// Should NOT show setup message (no ?setup=1)
|
||||||
|
await expect(page.locator('[role="status"]:has-text("Welcome")')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
11
tests/e2e/health.spec.js
Normal file
11
tests/e2e/health.spec.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Health endpoint', () => {
|
||||||
|
test('returns OK status', async ({ request }) => {
|
||||||
|
const resp = await request.get('/health');
|
||||||
|
expect(resp.ok()).toBe(true);
|
||||||
|
const body = await resp.json();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
155
tests/e2e/login.spec.js
Normal file
155
tests/e2e/login.spec.js
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Login page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Branding', () => {
|
||||||
|
test('page title contains Porchlight', async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle(/Porchlight/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page title does not contain FastAPI', async ({ page }) => {
|
||||||
|
const title = await page.title();
|
||||||
|
expect(title).not.toContain('FastAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('favicon link points to /static/favicon.png', async ({ page }) => {
|
||||||
|
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/static/favicon.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('favicon file is served', async ({ request }) => {
|
||||||
|
const resp = await request.get('/static/favicon.png');
|
||||||
|
expect(resp.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Site header & logo', () => {
|
||||||
|
test('site header is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('.site-header')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logo image is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('.site-logo')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logo src is /static/logo.svg', async ({ page }) => {
|
||||||
|
await expect(page.locator('.site-logo')).toHaveAttribute('src', '/static/logo.svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logo SVG file is served', async ({ request }) => {
|
||||||
|
const resp = await request.get('/static/logo.svg');
|
||||||
|
expect(resp.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site title text is Porchlight', async ({ page }) => {
|
||||||
|
const siteTitle = page.locator('.site-title');
|
||||||
|
await expect(siteTitle).toBeVisible();
|
||||||
|
await expect(siteTitle).toHaveText('Porchlight');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Accessibility', () => {
|
||||||
|
test('skip link is present', async ({ page }) => {
|
||||||
|
await expect(page.locator('.skip-link')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('main landmark with id="main" exists', async ({ page }) => {
|
||||||
|
await expect(page.locator('main#main')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('polite live region exists', async ({ page }) => {
|
||||||
|
await expect(page.locator('#live[aria-live="polite"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Login form structure', () => {
|
||||||
|
test('H1 says "Sign in"', async ({ page }) => {
|
||||||
|
await expect(page.locator('h1')).toHaveText('Sign in');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password login form exists', async ({ page }) => {
|
||||||
|
await expect(page.locator('form[hx-post="/login/password"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('username input is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('#username')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password input is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('#password')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit button is visible', async ({ page }) => {
|
||||||
|
await expect(page.locator('form[hx-post="/login/password"] button[type="submit"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WebAuthn login form exists', async ({ page }) => {
|
||||||
|
await expect(page.locator('#webauthn-login-form')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Theme / styling', () => {
|
||||||
|
test('body background is themed', async ({ page }) => {
|
||||||
|
const bgColor = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||||
|
expect(['rgb(250, 250, 249)', 'rgb(28, 25, 23)']).toContain(bgColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('button uses amber accent color', async ({ page }) => {
|
||||||
|
const btnBg = await page.evaluate(() => {
|
||||||
|
const btn = document.querySelector('button[type="submit"]');
|
||||||
|
return getComputedStyle(btn).backgroundColor;
|
||||||
|
});
|
||||||
|
expect(['rgb(217, 119, 6)', 'rgb(245, 158, 11)']).toContain(btnBg);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sections have surface background', async ({ page }) => {
|
||||||
|
const sectionBg = await page.evaluate(() => {
|
||||||
|
const section = document.querySelector('section');
|
||||||
|
return getComputedStyle(section).backgroundColor;
|
||||||
|
});
|
||||||
|
expect(['rgb(245, 245, 244)', 'rgb(41, 37, 36)']).toContain(sectionBg);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sections have solid border', async ({ page }) => {
|
||||||
|
const sectionBorder = await page.evaluate(() => {
|
||||||
|
const section = document.querySelector('section');
|
||||||
|
return getComputedStyle(section).borderStyle;
|
||||||
|
});
|
||||||
|
expect(sectionBorder).toBe('solid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Static assets', () => {
|
||||||
|
test('style.css is served', async ({ request }) => {
|
||||||
|
const resp = await request.get('/static/style.css');
|
||||||
|
expect(resp.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSS contains --accent custom property', async ({ request }) => {
|
||||||
|
const resp = await request.get('/static/style.css');
|
||||||
|
const body = await resp.text();
|
||||||
|
expect(body).toContain('--accent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSS contains amber accent color #d97706', async ({ request }) => {
|
||||||
|
const resp = await request.get('/static/style.css');
|
||||||
|
const body = await resp.text();
|
||||||
|
expect(body).toContain('#d97706');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSS contains dark mode media query', async ({ request }) => {
|
||||||
|
const resp = await request.get('/static/style.css');
|
||||||
|
const body = await resp.text();
|
||||||
|
expect(body).toContain('prefers-color-scheme: dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSS contains reduced motion media query', async ({ request }) => {
|
||||||
|
const resp = await request.get('/static/style.css');
|
||||||
|
const body = await resp.text();
|
||||||
|
expect(body).toContain('prefers-reduced-motion');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
tests/e2e/password-auth.spec.js
Normal file
61
tests/e2e/password-auth.spec.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
||||||
|
|
||||||
|
test.describe('Password authentication', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Error states', () => {
|
||||||
|
test('shows error for nonexistent user', async ({ page }) => {
|
||||||
|
await page.fill('#username', 'nobody');
|
||||||
|
await page.fill('#password', 'whatever');
|
||||||
|
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
||||||
|
|
||||||
|
const alert = page.locator('[role="alert"]');
|
||||||
|
await expect(alert).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(alert).toContainText('Invalid username or password');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows error for wrong password', async ({ page }) => {
|
||||||
|
await page.fill('#username', fixtures.login_username);
|
||||||
|
await page.fill('#password', 'wrongpassword');
|
||||||
|
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
||||||
|
|
||||||
|
const alert = page.locator('[role="alert"]');
|
||||||
|
await expect(alert).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(alert).toContainText('Invalid username or password');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Successful login', () => {
|
||||||
|
test('redirects to credentials page', async ({ page }) => {
|
||||||
|
await page.fill('#username', fixtures.login_username);
|
||||||
|
await page.fill('#password', fixtures.login_password);
|
||||||
|
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
||||||
|
|
||||||
|
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
||||||
|
expect(page.url()).toContain('/manage/credentials');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Form validation attributes', () => {
|
||||||
|
test('username has required attribute', async ({ page }) => {
|
||||||
|
await expect(page.locator('#username')).toHaveAttribute('required', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password has required attribute', async ({ page }) => {
|
||||||
|
await expect(page.locator('#password')).toHaveAttribute('required', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('username autocomplete is "username"', async ({ page }) => {
|
||||||
|
await expect(page.locator('#username')).toHaveAttribute('autocomplete', 'username');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password autocomplete is "current-password"', async ({ page }) => {
|
||||||
|
await expect(page.locator('#password')).toHaveAttribute('autocomplete', 'current-password');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
19
tests/e2e/registration.spec.js
Normal file
19
tests/e2e/registration.spec.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
||||||
|
|
||||||
|
test.describe('Registration', () => {
|
||||||
|
test('invalid token returns 400', async ({ page }) => {
|
||||||
|
const resp = await page.goto('/register/invalid-token-12345');
|
||||||
|
expect(resp.status()).toBe(400);
|
||||||
|
await expect(page.locator('body')).toContainText('Invalid or expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('used token returns 400', async ({ page }) => {
|
||||||
|
expect(fixtures.used_token).toBeTruthy();
|
||||||
|
const resp = await page.goto(`/register/${fixtures.used_token}`);
|
||||||
|
expect(resp.status()).toBe(400);
|
||||||
|
await expect(page.locator('body')).toContainText('Invalid or expired');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
// tests/e2e/test_auth_guard.js
|
|
||||||
// Tests that protected routes redirect unauthenticated users to /login.
|
|
||||||
|
|
||||||
const { TARGET_URL, run } = require('./helpers');
|
|
||||||
|
|
||||||
run(async (page, assert) => {
|
|
||||||
// ---- Unauthenticated access to /manage/credentials ----
|
|
||||||
console.log('\n--- Auth guard: /manage/credentials ---');
|
|
||||||
await page.goto(`${TARGET_URL}/manage/credentials`);
|
|
||||||
await page.waitForURL('**/login', { timeout: 5000 });
|
|
||||||
assert(page.url().includes('/login'), 'GET /manage/credentials redirects to /login');
|
|
||||||
|
|
||||||
// ---- Unauthenticated access to /manage/credentials?setup=1 ----
|
|
||||||
console.log('\n--- Auth guard: /manage/credentials?setup=1 ---');
|
|
||||||
await page.goto(`${TARGET_URL}/manage/credentials?setup=1`);
|
|
||||||
await page.waitForURL('**/login', { timeout: 5000 });
|
|
||||||
assert(page.url().includes('/login'), 'GET /manage/credentials?setup=1 redirects to /login');
|
|
||||||
});
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
// tests/e2e/test_credentials.js
|
|
||||||
// Tests credential management page: structure, password set/change, validation errors.
|
|
||||||
|
|
||||||
const { TARGET_URL, run } = require('./helpers');
|
|
||||||
|
|
||||||
run(async (page, assert) => {
|
|
||||||
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
|
||||||
|
|
||||||
// ---- Setup: Log in with dedicated credentials user ----
|
|
||||||
console.log('\n--- Setup: login ---');
|
|
||||||
await page.goto(`${TARGET_URL}/login`);
|
|
||||||
await page.fill('#username', fixtures.cred_username);
|
|
||||||
await page.fill('#password', fixtures.cred_password);
|
|
||||||
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
|
||||||
|
|
||||||
// ---- Page structure ----
|
|
||||||
console.log('\n--- Credentials page structure ---');
|
|
||||||
const title = await page.title();
|
|
||||||
assert(title.includes('Credentials'), `Title contains "Credentials" (got: "${title}")`);
|
|
||||||
assert(title.includes('Porchlight'), `Title contains "Porchlight" (got: "${title}")`);
|
|
||||||
|
|
||||||
const h1 = await page.locator('h1').textContent();
|
|
||||||
assert(h1 === 'Credentials', `H1 says "Credentials" (got: "${h1}")`);
|
|
||||||
|
|
||||||
// Security keys section
|
|
||||||
const securityKeysH2 = page.locator('h2:has-text("Security keys")');
|
|
||||||
assert(await securityKeysH2.isVisible(), 'Security keys heading visible');
|
|
||||||
|
|
||||||
const registerBtn = page.locator('#webauthn-register-btn');
|
|
||||||
assert(await registerBtn.isVisible(), 'Add security key button visible');
|
|
||||||
|
|
||||||
// Password section
|
|
||||||
const passwordH2 = page.locator('h2:has-text("Password")');
|
|
||||||
assert(await passwordH2.isVisible(), 'Password heading visible');
|
|
||||||
|
|
||||||
const passwordSection = page.locator('#password-section');
|
|
||||||
assert(await passwordSection.isVisible(), 'Password section visible');
|
|
||||||
|
|
||||||
// ---- Password validation: mismatch ----
|
|
||||||
console.log('\n--- Password validation: mismatch ---');
|
|
||||||
await page.fill('#password', 'newpassword1');
|
|
||||||
await page.fill('#confirm', 'newpassword2');
|
|
||||||
await page.click('#password-section button[type="submit"]');
|
|
||||||
|
|
||||||
await page.waitForSelector('#password-section [role="alert"]', { timeout: 5000 });
|
|
||||||
const mismatchErr = await page.locator('#password-section [role="alert"]').textContent();
|
|
||||||
assert(
|
|
||||||
mismatchErr.includes('do not match'),
|
|
||||||
`Shows mismatch error (got: "${mismatchErr}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Password validation: minlength enforced client-side ----
|
|
||||||
console.log('\n--- Password validation: minlength attribute ---');
|
|
||||||
// Reload page to clear HTMX state (the form was replaced by the error div)
|
|
||||||
await page.goto(`${TARGET_URL}/manage/credentials`);
|
|
||||||
const pwMinlength = await page.locator('#password').getAttribute('minlength');
|
|
||||||
assert(pwMinlength === '8', `Password input has minlength="8" (got: "${pwMinlength}")`);
|
|
||||||
const confirmMinlength = await page.locator('#confirm').getAttribute('minlength');
|
|
||||||
assert(confirmMinlength === '8', `Confirm input has minlength="8" (got: "${confirmMinlength}")`);
|
|
||||||
|
|
||||||
// ---- Password change: success ----
|
|
||||||
console.log('\n--- Password change: success ---');
|
|
||||||
await page.goto(`${TARGET_URL}/manage/credentials`);
|
|
||||||
await page.fill('#password', 'newpassword123');
|
|
||||||
await page.fill('#confirm', 'newpassword123');
|
|
||||||
await page.click('#password-section button[type="submit"]');
|
|
||||||
|
|
||||||
await page.waitForSelector('#password-section [role="status"]', { timeout: 5000 });
|
|
||||||
const successMsg = await page.locator('#password-section [role="status"]').textContent();
|
|
||||||
assert(
|
|
||||||
successMsg.includes('Password updated'),
|
|
||||||
`Shows success message (got: "${successMsg}")`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
// tests/e2e/test_full_flow.js
|
|
||||||
// Full user journey: magic link registration -> set password -> logout -> login.
|
|
||||||
|
|
||||||
const { TARGET_URL, run } = require('./helpers');
|
|
||||||
|
|
||||||
run(async (page, assert) => {
|
|
||||||
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
|
||||||
assert(fixtures.register_token, 'Test fixtures loaded (register_token present)');
|
|
||||||
|
|
||||||
// ---- Step 1: Register via magic link ----
|
|
||||||
console.log('\n--- Magic link registration ---');
|
|
||||||
await page.goto(`${TARGET_URL}/register/${fixtures.register_token}`);
|
|
||||||
|
|
||||||
// Should redirect to /manage/credentials?setup=1
|
|
||||||
await page.waitForURL('**/manage/credentials?setup=1', { timeout: 5000 });
|
|
||||||
assert(
|
|
||||||
page.url().includes('/manage/credentials'),
|
|
||||||
`Redirected to credentials page (url: ${page.url()})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should show welcome message
|
|
||||||
const welcome = page.locator('[role="status"]');
|
|
||||||
assert(await welcome.isVisible(), 'Welcome/setup message is visible');
|
|
||||||
const welcomeText = await welcome.textContent();
|
|
||||||
assert(
|
|
||||||
welcomeText.includes('Welcome'),
|
|
||||||
`Welcome message shown (got: "${welcomeText}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Page title should be Porchlight
|
|
||||||
const title = await page.title();
|
|
||||||
assert(title.includes('Porchlight'), 'Credentials page title contains Porchlight');
|
|
||||||
|
|
||||||
// ---- Step 2: Set password ----
|
|
||||||
console.log('\n--- Set password ---');
|
|
||||||
const passwordInput = page.locator('#password');
|
|
||||||
const confirmInput = page.locator('#confirm');
|
|
||||||
assert(await passwordInput.isVisible(), 'Password input is visible');
|
|
||||||
assert(await confirmInput.isVisible(), 'Confirm password input is visible');
|
|
||||||
|
|
||||||
await passwordInput.fill('mypassword123');
|
|
||||||
await confirmInput.fill('mypassword123');
|
|
||||||
await page.click('#password-section button[type="submit"]');
|
|
||||||
|
|
||||||
// Wait for HTMX response — password-section innerHTML gets replaced
|
|
||||||
// The success message uses role="status"
|
|
||||||
const successMsg = page.locator('#password-section [role="status"]');
|
|
||||||
await successMsg.waitFor({ timeout: 5000 });
|
|
||||||
const successText = await successMsg.textContent();
|
|
||||||
assert(
|
|
||||||
successText.includes('Password updated'),
|
|
||||||
`Password set successfully (got: "${successText}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Step 3: Logout ----
|
|
||||||
console.log('\n--- Logout ---');
|
|
||||||
// POST /logout returns an HX-Redirect header, not a standard redirect.
|
|
||||||
// Use page.request to call it, then navigate manually.
|
|
||||||
await page.request.post(`${TARGET_URL}/logout`);
|
|
||||||
|
|
||||||
// Navigate to credentials — should redirect to login since we're logged out
|
|
||||||
await page.goto(`${TARGET_URL}/manage/credentials`);
|
|
||||||
await page.waitForURL('**/login', { timeout: 5000 });
|
|
||||||
assert(page.url().includes('/login'), 'Redirected to login after logout');
|
|
||||||
|
|
||||||
// ---- Step 4: Login with the password we just set ----
|
|
||||||
console.log('\n--- Login with new password ---');
|
|
||||||
await page.fill('#username', fixtures.register_username);
|
|
||||||
await page.fill('#password', 'mypassword123');
|
|
||||||
|
|
||||||
// Submit via HTMX — on success, HX-Redirect header triggers redirect
|
|
||||||
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
|
||||||
|
|
||||||
// Wait for redirect to credentials page
|
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
|
||||||
assert(
|
|
||||||
page.url().includes('/manage/credentials'),
|
|
||||||
`Login succeeded, redirected to credentials (url: ${page.url()})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should NOT show setup message (no ?setup=1)
|
|
||||||
const setupMsgCount = await page.locator('[role="status"]:has-text("Welcome")').count();
|
|
||||||
assert(setupMsgCount === 0, 'No welcome/setup message on normal login');
|
|
||||||
});
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// tests/e2e/test_health.js
|
|
||||||
// Smoke test: health endpoint returns OK.
|
|
||||||
|
|
||||||
const { TARGET_URL, run } = require('./helpers');
|
|
||||||
|
|
||||||
run(async (page, assert) => {
|
|
||||||
console.log('\n--- Health endpoint ---');
|
|
||||||
const resp = await page.request.get(`${TARGET_URL}/health`);
|
|
||||||
assert(resp.ok(), `Health endpoint returns 200 (status: ${resp.status()})`);
|
|
||||||
|
|
||||||
const body = await resp.json();
|
|
||||||
assert(body.status === 'ok', `Health response is {"status":"ok"} (got: ${JSON.stringify(body)})`);
|
|
||||||
});
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
// Porchlight — Login page e2e test
|
|
||||||
//
|
|
||||||
// Tests branding, page structure, accessibility, theme, and responsive layout.
|
|
||||||
//
|
|
||||||
const { TARGET_URL, run } = require('./helpers');
|
|
||||||
|
|
||||||
run(async (page, assert) => {
|
|
||||||
// ---- Branding ----
|
|
||||||
console.log('\n--- Branding ---');
|
|
||||||
await page.goto(`${TARGET_URL}/login`);
|
|
||||||
|
|
||||||
const title = await page.title();
|
|
||||||
assert(title.includes('Porchlight'), `Page title contains "Porchlight" (got: "${title}")`);
|
|
||||||
assert(!title.includes('FastAPI'), `Page title does not contain "FastAPI" (got: "${title}")`);
|
|
||||||
|
|
||||||
// ---- Favicon ----
|
|
||||||
console.log('\n--- Favicon ---');
|
|
||||||
const favicon = await page.locator('link[rel="icon"]').getAttribute('href');
|
|
||||||
assert(favicon === '/static/favicon.png', `Favicon link points to /static/favicon.png (got: "${favicon}")`);
|
|
||||||
|
|
||||||
const faviconResp = await page.request.get(`${TARGET_URL}/static/favicon.png`);
|
|
||||||
assert(faviconResp.ok(), `Favicon file is served (status: ${faviconResp.status()})`);
|
|
||||||
|
|
||||||
// ---- Site header & logo ----
|
|
||||||
console.log('\n--- Site header & logo ---');
|
|
||||||
const header = page.locator('.site-header');
|
|
||||||
assert(await header.isVisible(), 'Site header is visible');
|
|
||||||
|
|
||||||
const logo = page.locator('.site-logo');
|
|
||||||
assert(await logo.isVisible(), 'Logo image is visible');
|
|
||||||
|
|
||||||
const logoSrc = await logo.getAttribute('src');
|
|
||||||
assert(logoSrc === '/static/logo.svg', `Logo src is /static/logo.svg (got: "${logoSrc}")`);
|
|
||||||
|
|
||||||
const logoResp = await page.request.get(`${TARGET_URL}/static/logo.svg`);
|
|
||||||
assert(logoResp.ok(), `Logo SVG file is served (status: ${logoResp.status()})`);
|
|
||||||
|
|
||||||
const siteTitle = page.locator('.site-title');
|
|
||||||
assert(await siteTitle.isVisible(), 'Site title text is visible');
|
|
||||||
const siteTitleText = await siteTitle.textContent();
|
|
||||||
assert(siteTitleText.trim() === 'Porchlight', `Site title text is "Porchlight" (got: "${siteTitleText.trim()}")`);
|
|
||||||
|
|
||||||
// ---- Accessibility ----
|
|
||||||
console.log('\n--- Accessibility ---');
|
|
||||||
const skipLink = page.locator('.skip-link');
|
|
||||||
assert(await skipLink.count() === 1, 'Skip link is present');
|
|
||||||
|
|
||||||
const main = page.locator('main#main');
|
|
||||||
assert(await main.count() === 1, 'Main landmark with id="main" exists');
|
|
||||||
|
|
||||||
const liveRegion = page.locator('#live[aria-live="polite"]');
|
|
||||||
assert(await liveRegion.count() === 1, 'Polite live region exists');
|
|
||||||
|
|
||||||
// ---- Login form structure ----
|
|
||||||
console.log('\n--- Login form structure ---');
|
|
||||||
const h1 = page.locator('h1');
|
|
||||||
assert(await h1.textContent() === 'Sign in', `H1 says "Sign in" (got: "${await h1.textContent()}")`);
|
|
||||||
|
|
||||||
const passwordForm = page.locator('form[hx-post="/login/password"]');
|
|
||||||
assert(await passwordForm.count() === 1, 'Password login form exists');
|
|
||||||
|
|
||||||
const usernameInput = page.locator('#username');
|
|
||||||
assert(await usernameInput.isVisible(), 'Username input is visible');
|
|
||||||
|
|
||||||
const passwordInput = page.locator('#password');
|
|
||||||
assert(await passwordInput.isVisible(), 'Password input is visible');
|
|
||||||
|
|
||||||
const submitBtn = passwordForm.locator('button[type="submit"]');
|
|
||||||
assert(await submitBtn.isVisible(), 'Submit button is visible');
|
|
||||||
|
|
||||||
const webauthnForm = page.locator('#webauthn-login-form');
|
|
||||||
assert(await webauthnForm.count() === 1, 'WebAuthn login form exists');
|
|
||||||
|
|
||||||
// ---- Theme / styling ----
|
|
||||||
console.log('\n--- Theme / styling ---');
|
|
||||||
const bgColor = await page.evaluate(() => {
|
|
||||||
return getComputedStyle(document.body).backgroundColor;
|
|
||||||
});
|
|
||||||
assert(
|
|
||||||
bgColor === 'rgb(250, 250, 249)' || bgColor === 'rgb(28, 25, 23)',
|
|
||||||
`Body background is themed (got: "${bgColor}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
const btnBg = await page.evaluate(() => {
|
|
||||||
const btn = document.querySelector('button[type="submit"]');
|
|
||||||
return getComputedStyle(btn).backgroundColor;
|
|
||||||
});
|
|
||||||
assert(
|
|
||||||
btnBg === 'rgb(217, 119, 6)' || btnBg === 'rgb(245, 158, 11)',
|
|
||||||
`Button uses amber accent color (got: "${btnBg}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Section card styling ----
|
|
||||||
console.log('\n--- Section cards ---');
|
|
||||||
const sectionBg = await page.evaluate(() => {
|
|
||||||
const section = document.querySelector('section');
|
|
||||||
return getComputedStyle(section).backgroundColor;
|
|
||||||
});
|
|
||||||
assert(
|
|
||||||
sectionBg === 'rgb(245, 245, 244)' || sectionBg === 'rgb(41, 37, 36)',
|
|
||||||
`Sections have surface background (got: "${sectionBg}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
const sectionBorder = await page.evaluate(() => {
|
|
||||||
const section = document.querySelector('section');
|
|
||||||
return getComputedStyle(section).borderStyle;
|
|
||||||
});
|
|
||||||
assert(sectionBorder === 'solid', `Sections have solid border (got: "${sectionBorder}")`);
|
|
||||||
|
|
||||||
// ---- Static assets ----
|
|
||||||
console.log('\n--- Static assets ---');
|
|
||||||
const cssResp = await page.request.get(`${TARGET_URL}/static/style.css`);
|
|
||||||
assert(cssResp.ok(), `style.css is served (status: ${cssResp.status()})`);
|
|
||||||
const cssBody = await cssResp.text();
|
|
||||||
assert(cssBody.includes('--accent'), 'CSS contains --accent custom property');
|
|
||||||
assert(cssBody.includes('#d97706'), 'CSS contains amber accent color #d97706');
|
|
||||||
assert(cssBody.includes('prefers-color-scheme: dark'), 'CSS contains dark mode media query');
|
|
||||||
assert(cssBody.includes('prefers-reduced-motion'), 'CSS contains reduced motion media query');
|
|
||||||
});
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
// tests/e2e/test_password_auth.js
|
|
||||||
// Tests password login error states: wrong password, nonexistent user, form validation.
|
|
||||||
// Also tests successful login with seeded fixtures.
|
|
||||||
|
|
||||||
const { TARGET_URL, run } = require('./helpers');
|
|
||||||
|
|
||||||
run(async (page, assert) => {
|
|
||||||
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
|
||||||
|
|
||||||
// ---- Test: Nonexistent user ----
|
|
||||||
console.log('\n--- Login: nonexistent user ---');
|
|
||||||
await page.goto(`${TARGET_URL}/login`);
|
|
||||||
await page.fill('#username', 'nobody');
|
|
||||||
await page.fill('#password', 'whatever');
|
|
||||||
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
|
||||||
|
|
||||||
await page.waitForSelector('[role="alert"]', { timeout: 5000 });
|
|
||||||
const error1 = await page.locator('[role="alert"]').textContent();
|
|
||||||
assert(
|
|
||||||
error1.includes('Invalid username or password'),
|
|
||||||
`Error shown for nonexistent user (got: "${error1}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Test: Wrong password for existing user ----
|
|
||||||
console.log('\n--- Login: wrong password ---');
|
|
||||||
await page.goto(`${TARGET_URL}/login`);
|
|
||||||
await page.fill('#username', fixtures.login_username);
|
|
||||||
await page.fill('#password', 'wrongpassword');
|
|
||||||
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
|
||||||
|
|
||||||
await page.waitForSelector('[role="alert"]', { timeout: 5000 });
|
|
||||||
const error2 = await page.locator('[role="alert"]').textContent();
|
|
||||||
assert(
|
|
||||||
error2.includes('Invalid username or password'),
|
|
||||||
`Error shown for wrong password (got: "${error2}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Test: Successful login ----
|
|
||||||
console.log('\n--- Login: correct password ---');
|
|
||||||
await page.goto(`${TARGET_URL}/login`);
|
|
||||||
await page.fill('#username', fixtures.login_username);
|
|
||||||
await page.fill('#password', fixtures.login_password);
|
|
||||||
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
|
||||||
|
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
|
||||||
assert(
|
|
||||||
page.url().includes('/manage/credentials'),
|
|
||||||
`Successful login redirects to credentials (url: ${page.url()})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Test: Form validation attributes ----
|
|
||||||
console.log('\n--- Form validation attributes ---');
|
|
||||||
await page.goto(`${TARGET_URL}/login`);
|
|
||||||
const usernameRequired = await page.locator('#username').getAttribute('required');
|
|
||||||
assert(usernameRequired !== null, 'Username has required attribute');
|
|
||||||
const passwordRequired = await page.locator('#password').getAttribute('required');
|
|
||||||
assert(passwordRequired !== null, 'Password has required attribute');
|
|
||||||
|
|
||||||
const usernameAutocomplete = await page.locator('#username').getAttribute('autocomplete');
|
|
||||||
assert(usernameAutocomplete === 'username', 'Username autocomplete is "username"');
|
|
||||||
const passwordAutocomplete = await page.locator('#password').getAttribute('autocomplete');
|
|
||||||
assert(passwordAutocomplete === 'current-password', 'Password autocomplete is "current-password"');
|
|
||||||
});
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
// tests/e2e/test_registration.js
|
|
||||||
// Tests magic link registration error states: invalid token, used token.
|
|
||||||
|
|
||||||
const { TARGET_URL, run } = require('./helpers');
|
|
||||||
|
|
||||||
run(async (page, assert) => {
|
|
||||||
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
|
||||||
|
|
||||||
// ---- Invalid token ----
|
|
||||||
console.log('\n--- Invalid registration token ---');
|
|
||||||
const resp1 = await page.goto(`${TARGET_URL}/register/invalid-token-12345`);
|
|
||||||
assert(resp1.status() === 400, `Invalid token returns 400 (got: ${resp1.status()})`);
|
|
||||||
const body1 = await page.locator('body').textContent();
|
|
||||||
assert(
|
|
||||||
body1.includes('Invalid or expired'),
|
|
||||||
`Shows invalid/expired message (got: "${body1.trim()}")`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Used token ----
|
|
||||||
console.log('\n--- Used registration token ---');
|
|
||||||
assert(fixtures.used_token, 'Used token fixture exists');
|
|
||||||
const resp2 = await page.goto(`${TARGET_URL}/register/${fixtures.used_token}`);
|
|
||||||
assert(resp2.status() === 400, `Used token returns 400 (got: ${resp2.status()})`);
|
|
||||||
const body2 = await page.locator('body').textContent();
|
|
||||||
assert(
|
|
||||||
body2.includes('Invalid or expired'),
|
|
||||||
`Shows invalid/expired for used token (got: "${body2.trim()}")`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue