diff --git a/tests/e2e/auth-guard.spec.js b/tests/e2e/auth-guard.spec.js new file mode 100644 index 0000000..b9f2466 --- /dev/null +++ b/tests/e2e/auth-guard.spec.js @@ -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'); + }); +}); diff --git a/tests/e2e/credentials.spec.js b/tests/e2e/credentials.spec.js new file mode 100644 index 0000000..0270822 --- /dev/null +++ b/tests/e2e/credentials.spec.js @@ -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'); + }); + }); +}); diff --git a/tests/e2e/full-flow.spec.js b/tests/e2e/full-flow.spec.js new file mode 100644 index 0000000..ed772ac --- /dev/null +++ b/tests/e2e/full-flow.spec.js @@ -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); + }); +}); diff --git a/tests/e2e/health.spec.js b/tests/e2e/health.spec.js new file mode 100644 index 0000000..fa9de1b --- /dev/null +++ b/tests/e2e/health.spec.js @@ -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'); + }); +}); diff --git a/tests/e2e/login.spec.js b/tests/e2e/login.spec.js new file mode 100644 index 0000000..393a84e --- /dev/null +++ b/tests/e2e/login.spec.js @@ -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'); + }); + }); +}); diff --git a/tests/e2e/password-auth.spec.js b/tests/e2e/password-auth.spec.js new file mode 100644 index 0000000..ee1d63c --- /dev/null +++ b/tests/e2e/password-auth.spec.js @@ -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'); + }); + }); +}); diff --git a/tests/e2e/registration.spec.js b/tests/e2e/registration.spec.js new file mode 100644 index 0000000..2f1ef7e --- /dev/null +++ b/tests/e2e/registration.spec.js @@ -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'); + }); +}); diff --git a/tests/e2e/test_auth_guard.js b/tests/e2e/test_auth_guard.js deleted file mode 100644 index 7586a2d..0000000 --- a/tests/e2e/test_auth_guard.js +++ /dev/null @@ -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'); -}); diff --git a/tests/e2e/test_credentials.js b/tests/e2e/test_credentials.js deleted file mode 100644 index 5f99dd8..0000000 --- a/tests/e2e/test_credentials.js +++ /dev/null @@ -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}")` - ); -}); diff --git a/tests/e2e/test_full_flow.js b/tests/e2e/test_full_flow.js deleted file mode 100644 index 85369a0..0000000 --- a/tests/e2e/test_full_flow.js +++ /dev/null @@ -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'); -}); diff --git a/tests/e2e/test_health.js b/tests/e2e/test_health.js deleted file mode 100644 index 98fade7..0000000 --- a/tests/e2e/test_health.js +++ /dev/null @@ -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)})`); -}); diff --git a/tests/e2e/test_login.js b/tests/e2e/test_login.js deleted file mode 100644 index 488cab7..0000000 --- a/tests/e2e/test_login.js +++ /dev/null @@ -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'); -}); diff --git a/tests/e2e/test_password_auth.js b/tests/e2e/test_password_auth.js deleted file mode 100644 index 641e8ae..0000000 --- a/tests/e2e/test_password_auth.js +++ /dev/null @@ -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"'); -}); diff --git a/tests/e2e/test_registration.js b/tests/e2e/test_registration.js deleted file mode 100644 index 73752c9..0000000 --- a/tests/e2e/test_registration.js +++ /dev/null @@ -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()}")` - ); -});