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.
This commit is contained in:
Johan Lundberg 2026-02-16 14:41:14 +01:00
parent dbd7449ea1
commit c381896de4
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
10 changed files with 422 additions and 30 deletions

49
tests/e2e/helpers.js Normal file
View file

@ -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 };

View file

@ -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

75
tests/e2e/setup_db.py Normal file
View file

@ -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())

View file

@ -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');
});

View file

@ -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}")`
);
});

View file

@ -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');
});

13
tests/e2e/test_health.js Normal file
View file

@ -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)})`);
});

View file

@ -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);
})();
});

View file

@ -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"');
});

View file

@ -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()}")`
);
});