From c381896de42adb40c63cb5716f42d81ad1e1e51d Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Mon, 16 Feb 2026 14:41:14 +0100 Subject: [PATCH] test: add comprehensive e2e test suite with shared helpers and DB seeding Extract shared test runner (helpers.js), add file-based SQLite with setup_db.py for fixture seeding, and add tests for auth guard, credentials management, full registration flow, health endpoint, password auth, and magic link registration errors. 66 checks across 7 test files. --- tests/e2e/helpers.js | 49 +++++++++++++++++++ tests/e2e/run.sh | 14 +++++- tests/e2e/setup_db.py | 75 +++++++++++++++++++++++++++++ tests/e2e/test_auth_guard.js | 18 +++++++ tests/e2e/test_credentials.js | 75 +++++++++++++++++++++++++++++ tests/e2e/test_full_flow.js | 84 +++++++++++++++++++++++++++++++++ tests/e2e/test_health.js | 13 +++++ tests/e2e/test_login.js | 32 ++----------- tests/e2e/test_password_auth.js | 63 +++++++++++++++++++++++++ tests/e2e/test_registration.js | 29 ++++++++++++ 10 files changed, 422 insertions(+), 30 deletions(-) create mode 100644 tests/e2e/helpers.js create mode 100644 tests/e2e/setup_db.py create mode 100644 tests/e2e/test_auth_guard.js create mode 100644 tests/e2e/test_credentials.js create mode 100644 tests/e2e/test_full_flow.js create mode 100644 tests/e2e/test_health.js create mode 100644 tests/e2e/test_password_auth.js create mode 100644 tests/e2e/test_registration.js diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js new file mode 100644 index 0000000..53f8673 --- /dev/null +++ b/tests/e2e/helpers.js @@ -0,0 +1,49 @@ +// tests/e2e/helpers.js +// Shared utilities for Porchlight e2e tests. + +const { chromium } = require('playwright'); + +const TARGET_URL = process.env.TARGET_URL || 'http://localhost:8099'; + +/** + * Simple test runner with pass/fail counting. + * + * Usage: + * const { run } = require('./helpers'); + * run(async (page, assert) => { + * await page.goto(TARGET_URL + '/login'); + * assert(true, 'page loaded'); + * }); + */ +async function run(testFn) { + let passed = 0; + let failed = 0; + + function assert(condition, description) { + if (condition) { + console.log(` PASS: ${description}`); + passed++; + } else { + console.log(` FAIL: ${description}`); + failed++; + } + } + + const headless = process.env.E2E_HEADLESS !== '0'; + const browser = await chromium.launch({ headless }); + const page = await browser.newPage(); + + try { + await testFn(page, assert); + } finally { + await browser.close(); + } + + console.log(`\n========================================`); + console.log(`Results: ${passed} passed, ${failed} failed`); + console.log(`========================================\n`); + + process.exit(failed > 0 ? 1 : 0); +} + +module.exports = { TARGET_URL, run }; diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 09e506b..767281b 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -16,11 +16,16 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" PORT="${E2E_PORT:-8099}" export TARGET_URL="http://127.0.0.1:${PORT}" +# --- Temp directory for e2e state --- +E2E_TMPDIR="$(mktemp -d)" +export OIDC_OP_SQLITE_PATH="${E2E_TMPDIR}/e2e_test.db" +export OIDC_OP_SIGNING_KEY_PATH="${E2E_TMPDIR}/keys" + # --- Start the app --- echo "Starting Porchlight on port ${PORT}..." +echo " DB: ${OIDC_OP_SQLITE_PATH}" OIDC_OP_ISSUER="${TARGET_URL}" \ OIDC_OP_DEBUG=true \ -OIDC_OP_SQLITE_PATH=:memory: \ uv run --directory "$PROJECT_ROOT" \ uvicorn fastapi_oidc_op.app:create_app \ --factory --host 127.0.0.1 --port "$PORT" \ @@ -31,6 +36,7 @@ cleanup() { echo "Stopping server (pid ${SERVER_PID})..." kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true + rm -rf "$E2E_TMPDIR" } trap cleanup EXIT @@ -48,6 +54,12 @@ for i in $(seq 1 30); do sleep 1 done +# --- Seed test data --- +echo "Seeding test data..." +E2E_FIXTURES=$(uv run --directory "$PROJECT_ROOT" python tests/e2e/setup_db.py) +export E2E_FIXTURES +echo "Test fixtures: ${E2E_FIXTURES}" + # --- Run tests --- FAILED=0 diff --git a/tests/e2e/setup_db.py b/tests/e2e/setup_db.py new file mode 100644 index 0000000..7958f4e --- /dev/null +++ b/tests/e2e/setup_db.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Seed the e2e test database with test fixtures. + +Outputs JSON with the created test data (magic link tokens, usernames, etc.) +so the JS tests can use them. + +Requires OIDC_OP_SQLITE_PATH env var pointing to the app's SQLite DB. +""" + +import asyncio +import json +import os +import sys + +import aiosqlite + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.invite.service import MagicLinkService +from fastapi_oidc_op.models import PasswordCredential, User +from fastapi_oidc_op.store.sqlite.repositories import ( + SQLiteCredentialRepository, + SQLiteMagicLinkRepository, + SQLiteUserRepository, +) + + +async def seed() -> None: + db_path = os.environ.get("OIDC_OP_SQLITE_PATH") + if not db_path: + print("OIDC_OP_SQLITE_PATH not set", file=sys.stderr) + sys.exit(1) + + db = await aiosqlite.connect(db_path) + db.row_factory = aiosqlite.Row + + user_repo = SQLiteUserRepository(db) + cred_repo = SQLiteCredentialRepository(db) + magic_link_repo = SQLiteMagicLinkRepository(db) + password_service = PasswordService() + magic_link_service = MagicLinkService(repo=magic_link_repo) + + result = {} + + # 1. Create a magic link for registration test + link = await magic_link_service.create(username="newuser") + result["register_token"] = link.token + result["register_username"] = "newuser" + + # 2. Create a user with a password for login test + user = User(userid="test-user-01", username="testuser", groups=["users"]) + await user_repo.create(user) + password_hash = password_service.hash("testpassword123") + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=password_hash)) + result["login_username"] = "testuser" + result["login_password"] = "testpassword123" + + # 3. Create a separate user for credentials management test + cred_user = User(userid="test-user-02", username="creduser", groups=["users"]) + await user_repo.create(cred_user) + cred_password_hash = password_service.hash("credpassword123") + await cred_repo.create_password(PasswordCredential(user_id=cred_user.userid, password_hash=cred_password_hash)) + result["cred_username"] = "creduser" + result["cred_password"] = "credpassword123" + + # 4. Create an expired/used magic link for negative test + expired_link = await magic_link_service.create(username="expired") + await magic_link_service.mark_used(expired_link.token) + result["used_token"] = expired_link.token + + await db.commit() + await db.close() + print(json.dumps(result)) + + +asyncio.run(seed()) diff --git a/tests/e2e/test_auth_guard.js b/tests/e2e/test_auth_guard.js new file mode 100644 index 0000000..7586a2d --- /dev/null +++ b/tests/e2e/test_auth_guard.js @@ -0,0 +1,18 @@ +// 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 new file mode 100644 index 0000000..5f99dd8 --- /dev/null +++ b/tests/e2e/test_credentials.js @@ -0,0 +1,75 @@ +// 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 new file mode 100644 index 0000000..85369a0 --- /dev/null +++ b/tests/e2e/test_full_flow.js @@ -0,0 +1,84 @@ +// 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 new file mode 100644 index 0000000..98fade7 --- /dev/null +++ b/tests/e2e/test_health.js @@ -0,0 +1,13 @@ +// 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 index c504371..488cab7 100644 --- a/tests/e2e/test_login.js +++ b/tests/e2e/test_login.js @@ -1,28 +1,10 @@ // Porchlight — Login page e2e test // // Tests branding, page structure, accessibility, theme, and responsive layout. -// Requires: TARGET_URL env var or defaults to http://localhost:8099 // -const { chromium } = require('playwright'); - -const TARGET_URL = process.env.TARGET_URL || 'http://localhost:8099'; - -(async () => { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); - let passed = 0; - let failed = 0; - - function assert(condition, description) { - if (condition) { - console.log(` PASS: ${description}`); - passed++; - } else { - console.log(` FAIL: ${description}`); - failed++; - } - } +const { TARGET_URL, run } = require('./helpers'); +run(async (page, assert) => { // ---- Branding ---- console.log('\n--- Branding ---'); await page.goto(`${TARGET_URL}/login`); @@ -134,12 +116,4 @@ const TARGET_URL = process.env.TARGET_URL || 'http://localhost:8099'; 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'); - - // ---- Summary ---- - console.log(`\n========================================`); - console.log(`Results: ${passed} passed, ${failed} failed`); - console.log(`========================================\n`); - - await browser.close(); - process.exit(failed > 0 ? 1 : 0); -})(); +}); diff --git a/tests/e2e/test_password_auth.js b/tests/e2e/test_password_auth.js new file mode 100644 index 0000000..641e8ae --- /dev/null +++ b/tests/e2e/test_password_auth.js @@ -0,0 +1,63 @@ +// 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 new file mode 100644 index 0000000..73752c9 --- /dev/null +++ b/tests/e2e/test_registration.js @@ -0,0 +1,29 @@ +// 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()}")` + ); +});