From 39c06620c4d4e2831cddced4346b6e1f6baad050 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 4 Dec 2025 04:44:58 +0000 Subject: [PATCH] Updated E2E tests. --- e2e/README.md | 2 +- e2e/playwright.config.ts | 10 ++-- e2e/tests/fixtures/passkey-helpers.ts | 52 ++++++++++----------- e2e/tests/fixtures/virtual-authenticator.ts | 6 +-- e2e/tests/global-setup.ts | 36 +++++++------- e2e/tests/global-teardown.ts | 12 ++--- e2e/tests/passkey.spec.ts | 40 ++++++++-------- 7 files changed, 79 insertions(+), 79 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 0d3d4ad..eb74fae 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -86,7 +86,7 @@ e2e/ - WebSocket challenge-response with virtual authenticator - Session token creation and validation -### Authentication Flow +### Authentication Flow - Passkey authentication via WebSocket - Credential verification - Session management diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 35334c6..f38b28c 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,7 +3,7 @@ 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 */ @@ -17,18 +17,18 @@ export default defineConfig({ ['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', }, diff --git a/e2e/tests/fixtures/passkey-helpers.ts b/e2e/tests/fixtures/passkey-helpers.ts index 456cf13..cb3cbae 100644 --- a/e2e/tests/fixtures/passkey-helpers.ts +++ b/e2e/tests/fixtures/passkey-helpers.ts @@ -57,34 +57,34 @@ export async function registerPasskey( 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: { @@ -108,16 +108,16 @@ export async function registerPasskey( })) || [], } } - + // 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, @@ -131,18 +131,18 @@ export async function registerPasskey( 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}`)) @@ -165,33 +165,33 @@ export async function authenticatePasskey( 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: { @@ -206,16 +206,16 @@ export async function authenticatePasskey( })) || [], } } - + // 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, @@ -230,18 +230,18 @@ export async function authenticatePasskey( 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}`)) diff --git a/e2e/tests/fixtures/virtual-authenticator.ts b/e2e/tests/fixtures/virtual-authenticator.ts index ed0a127..e30edb1 100644 --- a/e2e/tests/fixtures/virtual-authenticator.ts +++ b/e2e/tests/fixtures/virtual-authenticator.ts @@ -27,7 +27,7 @@ export async function createVirtualAuthenticator( 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 @@ -81,10 +81,10 @@ export const test = base.extend<{ 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) }, diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index 472dc9c..f02b73f 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -15,34 +15,34 @@ interface TestState { /** * 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', @@ -55,22 +55,22 @@ export default async function globalSetup() { }, 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]+)+)/) @@ -80,15 +80,15 @@ export default async function globalSetup() { 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) @@ -96,7 +96,7 @@ export default async function globalSetup() { } }) }) - + try { state.resetToken = await resetTokenPromise console.log(`\n โœ… Captured reset token: ${state.resetToken}\n`) @@ -105,9 +105,9 @@ export default async function globalSetup() { 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 index 21e3e7e..1bf8f33 100644 --- a/e2e/tests/global-teardown.ts +++ b/e2e/tests/global-teardown.ts @@ -13,17 +13,17 @@ interface TestState { /** * 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 { @@ -40,11 +40,11 @@ export default async function globalTeardown() { } 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') @@ -58,6 +58,6 @@ export default async function globalTeardown() { if (existsSync(file)) rmSync(file) } } - + console.log(' โœ… Cleanup complete\n') } diff --git a/e2e/tests/passkey.spec.ts b/e2e/tests/passkey.spec.ts index 3f6c788..2f90058 100644 --- a/e2e/tests/passkey.spec.ts +++ b/e2e/tests/passkey.spec.ts @@ -11,21 +11,21 @@ import { /** * 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 + * 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 @@ -48,20 +48,20 @@ test.describe('Passkey Authentication E2E', () => { // 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, { @@ -91,10 +91,10 @@ test.describe('Passkey Authentication E2E', () => { 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}`) }) @@ -102,12 +102,12 @@ test.describe('Passkey Authentication E2E', () => { 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}`) }) @@ -128,9 +128,9 @@ test.describe('Passkey Authentication E2E', () => { 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') @@ -153,10 +153,10 @@ test.describe('Passkey Authentication E2E', () => { 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`) }) @@ -164,7 +164,7 @@ test.describe('Passkey Authentication E2E', () => { 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: { @@ -172,7 +172,7 @@ test.describe('Passkey Authentication E2E', () => { }, failOnStatusCode: false, }) - + expect(response.status()).toBe(401) console.log(`โœ“ Logout successful, session invalidated`) }) @@ -188,7 +188,7 @@ test.describe('Session Management', () => { }, failOnStatusCode: false, }) - + // Server may return 400 (bad format) or 401 (unauthorized) expect([400, 401]).toContain(response.status()) console.log(`โœ“ Invalid token correctly rejected`) @@ -198,7 +198,7 @@ test.describe('Session Management', () => { const response = await page.request.post(`${baseUrl}/auth/api/validate`, { failOnStatusCode: false, }) - + expect(response.status()).toBe(401) console.log(`โœ“ Missing token correctly rejected`) })