From 1c985527cf48ea55b01700532d6221aeb5b69949 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 3 Dec 2025 02:52:39 +0000 Subject: [PATCH] Add E2E tests to register and verify passkey. --- e2e/.gitignore | 13 + e2e/README.md | 157 ++++++++++ e2e/package.json | 20 ++ e2e/playwright.config.ts | 50 +++ e2e/tests/fixtures/passkey-helpers.ts | 319 ++++++++++++++++++++ e2e/tests/fixtures/virtual-authenticator.ts | 93 ++++++ e2e/tests/global-setup.ts | 113 +++++++ e2e/tests/global-teardown.ts | 63 ++++ e2e/tests/passkey.spec.ts | 205 +++++++++++++ e2e/tsconfig.json | 15 + 10 files changed, 1048 insertions(+) create mode 100644 e2e/.gitignore create mode 100644 e2e/README.md create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/fixtures/passkey-helpers.ts create mode 100644 e2e/tests/fixtures/virtual-authenticator.ts create mode 100644 e2e/tests/global-setup.ts create mode 100644 e2e/tests/global-teardown.ts create mode 100644 e2e/tests/passkey.spec.ts create mode 100644 e2e/tsconfig.json diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..85857a3 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,13 @@ +# Dependencies +node_modules/ + +# Test artifacts +test-data/ +test-results/ +playwright-report/ + +# Playwright +.playwright/ + +# Bun +bun.lockb diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..0d3d4ad --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,157 @@ +# PasskeyAuth E2E Tests + +End-to-end tests for PasskeyAuth using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**. + +## Overview + +These tests exercise the complete WebAuthn/passkey authentication flow without requiring physical hardware. Chrome's DevTools Protocol provides a virtual authenticator that can: + +- Generate passkey credentials +- Sign authentication challenges +- Store resident keys (discoverable credentials) +- Simulate user verification (biometrics/PIN) + +## Prerequisites + +- Node.js 18+ +- Python with `uv` (for running the backend server) + +## Setup + +```bash +cd e2e +npm install +npm run install:browsers +``` + +## Running Tests + +### Basic Test Run + +```bash +npm test +``` + +This will: +1. Start a fresh PasskeyAuth server with a test database +2. Run all E2E tests against it +3. Clean up the server when done + +### Interactive Mode + +```bash +npm run test:ui +``` + +Opens Playwright's UI mode for interactive test debugging. + +### Headed Mode + +```bash +npm run test:headed +``` + +Runs tests with a visible browser window. + +### Debug Mode + +```bash +npm run test:debug +``` + +Runs tests with Playwright Inspector for step-by-step debugging. + +## Test Structure + +``` +e2e/ +├── playwright.config.ts # Playwright configuration +├── package.json +├── tsconfig.json +├── test-data/ # Test database (created at runtime) +│ └── test.sqlite +└── tests/ + ├── global-setup.ts # Creates fresh DB, captures reset token + ├── global-teardown.ts # Cleanup + ├── passkey.spec.ts # Main E2E tests + └── fixtures/ + ├── virtual-authenticator.ts # Virtual authenticator setup + └── passkey-helpers.ts # WebSocket helpers +``` + +## What's Tested + +### Registration Flow +- Bootstrap admin user registration via reset token +- WebSocket challenge-response with virtual authenticator +- Session token creation and validation + +### Authentication Flow +- Passkey authentication via WebSocket +- Credential verification +- Session management + +### Session Management +- Token validation (`/auth/api/validate`) +- User info retrieval (`/auth/api/user-info`) +- Logout (`/auth/api/logout`) +- Invalid/missing token rejection + +## How Virtual Authenticator Works + +The tests use Chrome DevTools Protocol (CDP) to create a virtual authenticator: + +```typescript +const cdpSession = await page.context().newCDPSession(page) +await cdpSession.send('WebAuthn.enable') +await cdpSession.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, +}) +``` + +This creates an in-browser authenticator that: +- Automatically responds to WebAuthn prompts +- Stores credentials persistently during the test session +- Simulates user verification without actual biometric input + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `BASE_URL` | Server URL | `http://localhost:4401` | +| `CI` | CI environment flag | - | +| `CLEANUP_TEST_DB` | Remove test DB after run | `false` | + +## Limitations + +1. **Chromium only**: Virtual authenticator is a Chrome DevTools feature +2. **No cross-origin**: Tests run on localhost; production-like origins need additional setup +3. **Single user per run**: Bootstrap creates one admin user; additional users need admin API + +## Debugging Tips + +1. **Check test database**: `e2e/test-data/test.sqlite` persists after tests +2. **View server output**: Global setup echoes server bootstrap to console +3. **Use trace viewer**: `npx playwright show-trace` on failure traces + +## CI Integration + +The tests are designed for CI environments: + +```yaml +- name: Run E2E Tests + run: | + cd e2e + npm ci + npm run install:browsers + npm test + env: + CI: true +``` diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..0b8ea5b --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "passkey-auth-e2e", + "version": "1.0.0", + "private": true, + "description": "E2E tests for PasskeyAuth using Playwright with Virtual Authenticator", + "type": "module", + "scripts": { + "test": "bunx playwright test", + "test:headed": "bunx playwright test --headed", + "test:debug": "bunx playwright test --debug", + "test:ui": "bunx playwright test --ui", + "report": "bunx playwright show-report", + "install:browsers": "bunx playwright install chromium" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@simplewebauthn/browser": "^13.1.2", + "@types/bun": "^1.3.3" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..35334c6 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Playwright configuration for PasskeyAuth E2E tests. + * Uses Chrome's Virtual Authenticator for automated passkey testing. + * + * Run with: bun run test + */ + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, // Run tests sequentially for passkey state consistency + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, // Single worker for database state consistency + reporter: [ + ['html', { open: 'never' }], + ['list'] + ], + + // Global setup/teardown for test database and server + globalSetup: './tests/global-setup.ts', + globalTeardown: './tests/global-teardown.ts', + + use: { + // Base URL for the passkey-auth server + baseURL: process.env.BASE_URL || 'http://localhost:4401', + + // Collect trace on failure for debugging + trace: 'on-first-retry', + + // Screenshot on failure + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // Chrome-specific settings for virtual authenticator + launchOptions: { + args: [ + '--enable-features=WebAuthenticationEnterpriseAttestation', + ], + }, + }, + }, + ], +}) diff --git a/e2e/tests/fixtures/passkey-helpers.ts b/e2e/tests/fixtures/passkey-helpers.ts new file mode 100644 index 0000000..456cf13 --- /dev/null +++ b/e2e/tests/fixtures/passkey-helpers.ts @@ -0,0 +1,319 @@ +import { type Page } from '@playwright/test' +import { existsSync, readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** + * WebSocket helpers for passkey registration and authentication. + * These functions mirror the frontend's passkey.js but work in a Playwright context. + */ + +export interface RegistrationResult { + user_uuid: string + credential_uuid: string + session_token: string + message: string +} + +export interface AuthenticationResult { + user_uuid: string + session_token: string +} + +/** + * Get the bootstrap reset token from the test state file. + */ +export function getBootstrapResetToken(): string | undefined { + const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json') + if (existsSync(stateFile)) { + try { + const state = JSON.parse(readFileSync(stateFile, 'utf-8')) + return state.resetToken + } catch { + return undefined + } + } + return undefined +} + +/** + * Perform passkey registration via WebSocket. + * This runs in the browser context using the virtual authenticator. + */ +export async function registerPasskey( + page: Page, + baseUrl: string, + options: { resetToken?: string; displayName?: string } = {} +): Promise { + return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => { + // Build WebSocket URL with query parameters + let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register` + const params: string[] = [] + if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`) + if (displayName) params.push(`name=${encodeURIComponent(displayName)}`) + if (params.length) wsUrl += `?${params.join('&')}` + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + console.log('WebSocket connected for registration') + } + + ws.onmessage = async (event) => { + const data = JSON.parse(event.data) + + // Check for error response + if (data.detail) { + ws.close() + reject(new Error(data.detail)) + return + } + + // Check if this is the final success response + if (data.session_token) { + ws.close() + resolve(data) + return + } + + // This should be the registration options from server + // Use the native WebAuthn API with the virtual authenticator + try { + // Convert base64url challenge to ArrayBuffer + const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) + + // Build the credential creation options + const publicKeyCredentialCreationOptions: CredentialCreationOptions = { + publicKey: { + challenge: challenge, + rp: { + name: data.rp.name, + id: data.rp.id, + }, + user: { + id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)), + name: data.user.name, + displayName: data.user.displayName, + }, + pubKeyCredParams: data.pubKeyCredParams, + authenticatorSelection: data.authenticatorSelection, + timeout: data.timeout, + attestation: data.attestation, + excludeCredentials: data.excludeCredentials?.map((cred: any) => ({ + ...cred, + id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)), + })) || [], + } + } + + // Create the credential using native WebAuthn API (virtual authenticator handles it) + const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential + + if (!credential) { + throw new Error('Failed to create credential') + } + + const response = credential.response as AuthenticatorAttestationResponse + + // Convert response to JSON format expected by server + const registrationResponse = { + id: credential.id, + rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''), + response: { + clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''), + attestationObject: btoa(String.fromCharCode(...new Uint8Array(response.attestationObject))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''), + transports: response.getTransports?.() || [], + }, + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + authenticatorAttachment: (credential as any).authenticatorAttachment, + } + + ws.send(JSON.stringify(registrationResponse)) + } catch (error: any) { + ws.close() + reject(new Error(error.message || 'Registration failed')) + } + } + + ws.onerror = () => { + reject(new Error('WebSocket error during registration')) + } + + ws.onclose = (event) => { + if (!event.wasClean && event.code !== 1000) { + reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) + } + } + }) + }, { baseUrl, resetToken: options.resetToken, displayName: options.displayName }) +} + +/** + * Perform passkey authentication via WebSocket. + * This runs in the browser context using the virtual authenticator. + */ +export async function authenticatePasskey( + page: Page, + baseUrl: string +): Promise { + return await page.evaluate(async ({ baseUrl }) => { + const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate` + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + console.log('WebSocket connected for authentication') + } + + ws.onmessage = async (event) => { + const data = JSON.parse(event.data) + + // Check for error response + if (data.detail) { + ws.close() + reject(new Error(data.detail)) + return + } + + // Check if this is the final success response + if (data.session_token) { + ws.close() + resolve(data) + return + } + + // This should be the authentication options from server + try { + // Convert base64url challenge to ArrayBuffer + const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) + + // Build the credential request options + const publicKeyCredentialRequestOptions: CredentialRequestOptions = { + publicKey: { + challenge: challenge, + rpId: data.rpId, + timeout: data.timeout, + userVerification: data.userVerification, + allowCredentials: data.allowCredentials?.map((cred: any) => ({ + type: cred.type, + id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)), + transports: cred.transports, + })) || [], + } + } + + // Get the credential using native WebAuthn API (virtual authenticator handles it) + const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential + + if (!credential) { + throw new Error('Failed to get credential') + } + + const response = credential.response as AuthenticatorAssertionResponse + + // Convert response to JSON format expected by server + const authenticationResponse = { + id: credential.id, + rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''), + response: { + clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''), + authenticatorData: btoa(String.fromCharCode(...new Uint8Array(response.authenticatorData))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''), + signature: btoa(String.fromCharCode(...new Uint8Array(response.signature))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''), + userHandle: response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(response.userHandle))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') : null, + }, + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + authenticatorAttachment: (credential as any).authenticatorAttachment, + } + + ws.send(JSON.stringify(authenticationResponse)) + } catch (error: any) { + ws.close() + reject(new Error(error.message || 'Authentication failed')) + } + } + + ws.onerror = () => { + reject(new Error('WebSocket error during authentication')) + } + + ws.onclose = (event) => { + if (!event.wasClean && event.code !== 1000) { + reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) + } + } + }) + }, { baseUrl }) +} + +/** + * Validate a session token via the API. + */ +export async function validateSession( + page: Page, + baseUrl: string, + sessionToken: string +): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> { + const response = await page.request.post(`${baseUrl}/auth/api/validate`, { + headers: { + 'Cookie': `__Host-auth=${sessionToken}`, + }, + }) + return await response.json() +} + +/** + * Get user info via the API. + */ +export async function getUserInfo( + page: Page, + baseUrl: string, + sessionToken: string +): Promise { + const response = await page.request.post(`${baseUrl}/auth/api/user-info`, { + headers: { + 'Cookie': `__Host-auth=${sessionToken}`, + }, + }) + return await response.json() +} + +/** + * Logout via the API. + */ +export async function logout( + page: Page, + baseUrl: string, + sessionToken: string +): Promise { + await page.request.post(`${baseUrl}/auth/api/logout`, { + headers: { + 'Cookie': `__Host-auth=${sessionToken}`, + }, + }) +} + +/** + * Create a device link for adding a new credential to an existing user. + */ +export async function createDeviceLink( + page: Page, + baseUrl: string, + sessionToken: string +): Promise<{ url: string; token: string }> { + const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, { + headers: { + 'Cookie': `__Host-auth=${sessionToken}`, + }, + }) + const data = await response.json() + // Extract token from URL (last path segment) + const url = new URL(data.url) + const token = url.pathname.split('/').pop() || '' + return { url: data.url, token } +} diff --git a/e2e/tests/fixtures/virtual-authenticator.ts b/e2e/tests/fixtures/virtual-authenticator.ts new file mode 100644 index 0000000..ed0a127 --- /dev/null +++ b/e2e/tests/fixtures/virtual-authenticator.ts @@ -0,0 +1,93 @@ +import { test as base, expect, type CDPSession, type Page } from '@playwright/test' + +/** + * Virtual Authenticator configuration for WebAuthn testing. + * Uses Chrome DevTools Protocol to create a software authenticator. + */ +export interface VirtualAuthenticatorOptions { + protocol?: 'ctap1/u2f' | 'ctap2' + transport?: 'usb' | 'nfc' | 'ble' | 'internal' + hasResidentKey?: boolean + hasUserVerification?: boolean + isUserVerified?: boolean + automaticPresenceSimulation?: boolean +} + +export interface VirtualAuthenticator { + authenticatorId: string + cdpSession: CDPSession +} + +/** + * Create a virtual authenticator using Chrome DevTools Protocol. + * This allows fully automated passkey registration and authentication. + */ +export async function createVirtualAuthenticator( + page: Page, + options: VirtualAuthenticatorOptions = {} +): Promise { + const cdpSession = await page.context().newCDPSession(page) + + // Enable WebAuthn in CDP + await cdpSession.send('WebAuthn.enable', { + enableUI: false, // Suppress any UI prompts + }) + + // Create the virtual authenticator with resident key support + const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: options.protocol ?? 'ctap2', + transport: options.transport ?? 'internal', + hasResidentKey: options.hasResidentKey ?? true, + hasUserVerification: options.hasUserVerification ?? true, + isUserVerified: options.isUserVerified ?? true, + automaticPresenceSimulation: options.automaticPresenceSimulation ?? true, + }, + }) + + return { authenticatorId, cdpSession } +} + +/** + * Remove a virtual authenticator. + */ +export async function removeVirtualAuthenticator( + authenticator: VirtualAuthenticator +): Promise { + await authenticator.cdpSession.send('WebAuthn.removeVirtualAuthenticator', { + authenticatorId: authenticator.authenticatorId, + }) + await authenticator.cdpSession.send('WebAuthn.disable') +} + +/** + * Get all credentials stored in a virtual authenticator. + */ +export async function getCredentials( + authenticator: VirtualAuthenticator +): Promise { + const result = await authenticator.cdpSession.send('WebAuthn.getCredentials', { + authenticatorId: authenticator.authenticatorId, + }) + return result.credentials +} + +/** + * Extended test fixture with virtual authenticator support. + */ +export const test = base.extend<{ + virtualAuthenticator: VirtualAuthenticator +}>({ + virtualAuthenticator: async ({ page }, use) => { + // Create virtual authenticator before test + const authenticator = await createVirtualAuthenticator(page) + + // Run the test + await use(authenticator) + + // Cleanup after test + await removeVirtualAuthenticator(authenticator) + }, +}) + +export { expect } diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts new file mode 100644 index 0000000..472dc9c --- /dev/null +++ b/e2e/tests/global-setup.ts @@ -0,0 +1,113 @@ +import { spawn } from 'child_process' +import { join, dirname } from 'path' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const testDataDir = join(__dirname, '..', 'test-data') +const stateFile = join(testDataDir, 'test-state.json') +const dbPath = join(testDataDir, 'test.sqlite') + +interface TestState { + resetToken?: string + serverPid?: number +} + +/** + * Global setup for E2E tests. + * + * This creates a fresh test database and starts the server, + * capturing the bootstrap reset token for initial user registration. + */ +export default async function globalSetup() { + console.log('\n🔧 Setting up E2E test environment...\n') + + // Create test data directory + if (!existsSync(testDataDir)) { + mkdirSync(testDataDir, { recursive: true }) + } + + // Remove old database for clean state + if (existsSync(dbPath)) { + console.log(' Removing old test database...') + rmSync(dbPath) + } + + // Remove any wal/shm files too + for (const ext of ['-wal', '-shm']) { + const file = dbPath + ext + if (existsSync(file)) rmSync(file) + } + + console.log(' Starting server with fresh database...') + + const state: TestState = {} + + // Start the server using Node's spawn + const serverProcess = spawn('uv', [ + 'run', 'passkey-auth', 'serve', ':4401', + '--rp-id', 'localhost', + '--origin', 'http://localhost:4401' + ], { + cwd: testDataDir, // Run from test-data so DB is created there + env: { + ...process.env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + state.serverPid = serverProcess.pid + + // Capture output to find reset token + const resetTokenPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for server bootstrap (30s)')) + }, 30000) + + let output = '' + + const handleData = (data: Buffer) => { + const text = data.toString() + output += text + process.stdout.write(text) // Echo to console + + // Look for the reset token URL in the output + // Format: http://localhost:4401/auth/{token} where token is word.word.word.word.word (dot separated) + const match = output.match(/http:\/\/localhost:\d+\/auth\/([a-z]+(?:\.[a-z]+)+)/) + if (match) { + clearTimeout(timeout) + // Wait a bit for server to fully start + setTimeout(() => resolve(match[1]), 1000) + } + } + + serverProcess.stdout?.on('data', handleData) + serverProcess.stderr?.on('data', handleData) + + serverProcess.on('error', (err) => { + clearTimeout(timeout) + reject(err) + }) + + serverProcess.on('exit', (code) => { + if (code !== 0 && code !== null) { + clearTimeout(timeout) + reject(new Error(`Server exited with code ${code}`)) + } + }) + }) + + try { + state.resetToken = await resetTokenPromise + console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`) + } catch (err) { + console.error('Failed to capture reset token:', err) + serverProcess.kill() + throw err + } + + // Save state for tests + writeFileSync(stateFile, JSON.stringify(state, null, 2)) + + console.log(' ✅ E2E test environment ready\n') +} diff --git a/e2e/tests/global-teardown.ts b/e2e/tests/global-teardown.ts new file mode 100644 index 0000000..21e3e7e --- /dev/null +++ b/e2e/tests/global-teardown.ts @@ -0,0 +1,63 @@ +import { join, dirname } from 'path' +import { existsSync, rmSync, readFileSync } from 'fs' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const testDataDir = join(__dirname, '..', 'test-data') +const stateFile = join(testDataDir, 'test-state.json') + +interface TestState { + resetToken?: string + serverPid?: number +} + +/** + * Global teardown for E2E tests. + * + * This cleans up the test server and optionally removes the test database. + */ +export default async function globalTeardown() { + console.log('\n🧹 Cleaning up E2E test environment...\n') + + // Read state file to get server PID + if (existsSync(stateFile)) { + try { + const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8')) + + if (state.serverPid) { + console.log(` Stopping server (PID: ${state.serverPid})...`) + try { + process.kill(state.serverPid, 'SIGTERM') + // Wait a moment for graceful shutdown + await new Promise(r => setTimeout(r, 500)) + } catch (err: any) { + // Process may already be dead + if (err.code !== 'ESRCH') { + console.warn(` Warning: Could not kill server: ${err.message}`) + } + } + } + } catch (err) { + console.warn(' Warning: Could not read state file') + } + + // Clean up state file + rmSync(stateFile, { force: true }) + } + + // Optionally clean up test database (keep it for debugging by default) + if (process.env.CLEANUP_TEST_DB === 'true') { + const dbPath = join(testDataDir, 'test.sqlite') + if (existsSync(dbPath)) { + console.log(' Removing test database...') + rmSync(dbPath) + } + // Remove wal/shm files too + for (const ext of ['-wal', '-shm']) { + const file = dbPath + ext + if (existsSync(file)) rmSync(file) + } + } + + console.log(' ✅ Cleanup complete\n') +} diff --git a/e2e/tests/passkey.spec.ts b/e2e/tests/passkey.spec.ts new file mode 100644 index 0000000..3f6c788 --- /dev/null +++ b/e2e/tests/passkey.spec.ts @@ -0,0 +1,205 @@ +import { test, expect } from './fixtures/virtual-authenticator' +import { + registerPasskey, + authenticatePasskey, + validateSession, + getUserInfo, + logout, + getBootstrapResetToken, + createDeviceLink, +} from './fixtures/passkey-helpers' + +/** + * E2E tests for PasskeyAuth using Chrome's Virtual Authenticator. + * + * These tests exercise the complete WebAuthn flow: + * 1. Registration via WebSocket using bootstrap reset token + * 2. Authentication via WebSocket + * 3. Session validation + * 4. User info retrieval + * 5. Logout + * + * The virtual authenticator simulates a hardware passkey device, + * allowing fully automated testing without physical hardware. + */ + +test.describe('Passkey Authentication E2E', () => { + const baseUrl = process.env.BASE_URL || 'http://localhost:4401' + + test.describe.configure({ mode: 'serial' }) + + // Shared state across tests in this describe block + let sessionToken: string + let userUuid: string + let credentialUuid: string + let resetToken: string | undefined + + test.beforeAll(() => { + // Get the bootstrap reset token from global setup + resetToken = getBootstrapResetToken() + if (!resetToken) { + console.warn('⚠️ No reset token found - registration test may fail') + } else { + console.log(`📝 Using reset token: ${resetToken}`) + } + }) + + test('should load the auth page', async ({ page }) => { + // Navigate to auth page to establish origin for WebAuthn + await page.goto('/auth/') + await expect(page).toHaveTitle(/.*/) + + // Page should load - 401 errors are expected since user is not logged in + await page.waitForTimeout(500) + + // Just verify the page loaded without JS errors (network 401s are OK) + console.log('✓ Auth page loaded successfully') + }) + + test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => { + test.skip(!resetToken, 'No reset token available from bootstrap') + + // Must visit the page first to establish origin + await page.goto('/auth/') + + // Perform registration via WebSocket with virtual authenticator + // Using the bootstrap reset token for the admin user + const result = await registerPasskey(page, baseUrl, { + resetToken: resetToken, + displayName: 'Admin User', + }) + + // Verify registration result + expect(result.session_token).toBeDefined() + expect(result.session_token).toHaveLength(16) + expect(result.user_uuid).toBeDefined() + expect(result.credential_uuid).toBeDefined() + expect(result.message).toContain('successfully') + + // Store for subsequent tests + sessionToken = result.session_token + userUuid = result.user_uuid + credentialUuid = result.credential_uuid + + console.log(`✓ Registered user: ${userUuid}`) + console.log(`✓ Credential: ${credentialUuid}`) + console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`) + }) + + test('should validate the session token', async ({ page }) => { + // Skip if registration didn't run + test.skip(!sessionToken, 'Requires successful registration') + + const validation = await validateSession(page, baseUrl, sessionToken) + + expect(validation.valid).toBe(true) + expect(validation.user_uuid).toBe(userUuid) + + console.log(`✓ Session validated for user: ${validation.user_uuid}`) + }) + + test('should retrieve user info', async ({ page }) => { + test.skip(!sessionToken, 'Requires successful registration') + + const userInfo = await getUserInfo(page, baseUrl, sessionToken) + + expect(userInfo.user.user_uuid).toBe(userUuid) + expect(userInfo.user.user_name).toBe('Admin User') + expect(userInfo.credentials).toBeDefined() + expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1) + + console.log(`✓ User info retrieved: ${userInfo.user.user_name}`) + console.log(`✓ Credentials count: ${userInfo.credentials.length}`) + }) + + test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => { + test.skip(!sessionToken, 'Requires successful registration') + + // Navigate to page (required for WebAuthn origin) + await page.goto('/auth/') + + // The virtual authenticator in this context is new and doesn't have credentials. + // Create a device link using the current session, then register a new credential. + const deviceLink = await createDeviceLink(page, baseUrl, sessionToken) + console.log(`✓ Created device link with token: ${deviceLink.token}`) + + // Register a new credential using the device link + const regResult = await registerPasskey(page, baseUrl, { + resetToken: deviceLink.token, + displayName: 'Admin User (test device)' + }) + + console.log(`✓ Added test credential: ${regResult.credential_uuid}`) + + // Now logout and authenticate with the fresh credential + await logout(page, baseUrl, regResult.session_token) + console.log('✓ Logged out') + + // Authenticate with the virtual authenticator (now has a valid credential) + const result = await authenticatePasskey(page, baseUrl) + + expect(result.session_token).toBeDefined() + expect(result.session_token).toHaveLength(16) + expect(result.user_uuid).toBe(userUuid) + + // Update session token for subsequent tests + sessionToken = result.session_token + + console.log(`✓ Authenticated as user: ${result.user_uuid}`) + console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`) + }) + + test('should validate new session after authentication', async ({ page }) => { + test.skip(!sessionToken, 'Requires successful authentication') + + const validation = await validateSession(page, baseUrl, sessionToken) + + expect(validation.valid).toBe(true) + expect(validation.user_uuid).toBe(userUuid) + + console.log(`✓ New session validated`) + }) + + test('should logout successfully', async ({ page }) => { + test.skip(!sessionToken, 'Requires valid session') + + await logout(page, baseUrl, sessionToken) + + // Session should no longer be valid + const response = await page.request.post(`${baseUrl}/auth/api/validate`, { + headers: { + 'Cookie': `__Host-auth=${sessionToken}`, + }, + failOnStatusCode: false, + }) + + expect(response.status()).toBe(401) + console.log(`✓ Logout successful, session invalidated`) + }) +}) + +test.describe('Session Management', () => { + const baseUrl = process.env.BASE_URL || 'http://localhost:4401' + + test('should reject invalid session token', async ({ page }) => { + const response = await page.request.post(`${baseUrl}/auth/api/validate`, { + headers: { + 'Cookie': '__Host-auth=invalid_token_123', + }, + failOnStatusCode: false, + }) + + // Server may return 400 (bad format) or 401 (unauthorized) + expect([400, 401]).toContain(response.status()) + console.log(`✓ Invalid token correctly rejected`) + }) + + test('should reject missing session token', async ({ page }) => { + const response = await page.request.post(`${baseUrl}/auth/api/validate`, { + failOnStatusCode: false, + }) + + expect(response.status()).toBe(401) + console.log(`✓ Missing token correctly rejected`) + }) +}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..484bcec --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["bun-types"] + }, + "include": ["tests/**/*.ts", "playwright.config.ts"], + "exclude": ["node_modules"] +}