Updated E2E tests.

This commit is contained in:
2025-12-04 04:44:58 +00:00
parent ea1ddbbe6f
commit 0355c55fc0
7 changed files with 79 additions and 79 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ e2e/
- WebSocket challenge-response with virtual authenticator - WebSocket challenge-response with virtual authenticator
- Session token creation and validation - Session token creation and validation
### Authentication Flow ### Authentication Flow
- Passkey authentication via WebSocket - Passkey authentication via WebSocket
- Credential verification - Credential verification
- Session management - Session management
+5 -5
View File
@@ -3,7 +3,7 @@ import { defineConfig, devices } from '@playwright/test'
/** /**
* Playwright configuration for PasskeyAuth E2E tests. * Playwright configuration for PasskeyAuth E2E tests.
* Uses Chrome's Virtual Authenticator for automated passkey testing. * Uses Chrome's Virtual Authenticator for automated passkey testing.
* *
* Run with: bun run test * Run with: bun run test
*/ */
@@ -17,18 +17,18 @@ export default defineConfig({
['html', { open: 'never' }], ['html', { open: 'never' }],
['list'] ['list']
], ],
// Global setup/teardown for test database and server // Global setup/teardown for test database and server
globalSetup: './tests/global-setup.ts', globalSetup: './tests/global-setup.ts',
globalTeardown: './tests/global-teardown.ts', globalTeardown: './tests/global-teardown.ts',
use: { use: {
// Base URL for the passkey-auth server // Base URL for the passkey-auth server
baseURL: process.env.BASE_URL || 'http://localhost:4401', baseURL: process.env.BASE_URL || 'http://localhost:4401',
// Collect trace on failure for debugging // Collect trace on failure for debugging
trace: 'on-first-retry', trace: 'on-first-retry',
// Screenshot on failure // Screenshot on failure
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
}, },
+26 -26
View File
@@ -57,34 +57,34 @@ export async function registerPasskey(
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for registration') console.log('WebSocket connected for registration')
} }
ws.onmessage = async (event) => { ws.onmessage = async (event) => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
// Check for error response // Check for error response
if (data.detail) { if (data.detail) {
ws.close() ws.close()
reject(new Error(data.detail)) reject(new Error(data.detail))
return return
} }
// Check if this is the final success response // Check if this is the final success response
if (data.session_token) { if (data.session_token) {
ws.close() ws.close()
resolve(data) resolve(data)
return return
} }
// This should be the registration options from server // This should be the registration options from server
// Use the native WebAuthn API with the virtual authenticator // Use the native WebAuthn API with the virtual authenticator
try { try {
// Convert base64url challenge to ArrayBuffer // Convert base64url challenge to ArrayBuffer
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
// Build the credential creation options // Build the credential creation options
const publicKeyCredentialCreationOptions: CredentialCreationOptions = { const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
publicKey: { publicKey: {
@@ -108,16 +108,16 @@ export async function registerPasskey(
})) || [], })) || [],
} }
} }
// Create the credential using native WebAuthn API (virtual authenticator handles it) // Create the credential using native WebAuthn API (virtual authenticator handles it)
const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential
if (!credential) { if (!credential) {
throw new Error('Failed to create credential') throw new Error('Failed to create credential')
} }
const response = credential.response as AuthenticatorAttestationResponse const response = credential.response as AuthenticatorAttestationResponse
// Convert response to JSON format expected by server // Convert response to JSON format expected by server
const registrationResponse = { const registrationResponse = {
id: credential.id, id: credential.id,
@@ -131,18 +131,18 @@ export async function registerPasskey(
clientExtensionResults: credential.getClientExtensionResults(), clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: (credential as any).authenticatorAttachment, authenticatorAttachment: (credential as any).authenticatorAttachment,
} }
ws.send(JSON.stringify(registrationResponse)) ws.send(JSON.stringify(registrationResponse))
} catch (error: any) { } catch (error: any) {
ws.close() ws.close()
reject(new Error(error.message || 'Registration failed')) reject(new Error(error.message || 'Registration failed'))
} }
} }
ws.onerror = () => { ws.onerror = () => {
reject(new Error('WebSocket error during registration')) reject(new Error('WebSocket error during registration'))
} }
ws.onclose = (event) => { ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) { if (!event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
@@ -165,33 +165,33 @@ export async function authenticatePasskey(
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected for authentication') console.log('WebSocket connected for authentication')
} }
ws.onmessage = async (event) => { ws.onmessage = async (event) => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
// Check for error response // Check for error response
if (data.detail) { if (data.detail) {
ws.close() ws.close()
reject(new Error(data.detail)) reject(new Error(data.detail))
return return
} }
// Check if this is the final success response // Check if this is the final success response
if (data.session_token) { if (data.session_token) {
ws.close() ws.close()
resolve(data) resolve(data)
return return
} }
// This should be the authentication options from server // This should be the authentication options from server
try { try {
// Convert base64url challenge to ArrayBuffer // Convert base64url challenge to ArrayBuffer
const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) const challenge = Uint8Array.from(atob(data.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
// Build the credential request options // Build the credential request options
const publicKeyCredentialRequestOptions: CredentialRequestOptions = { const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
publicKey: { publicKey: {
@@ -206,16 +206,16 @@ export async function authenticatePasskey(
})) || [], })) || [],
} }
} }
// Get the credential using native WebAuthn API (virtual authenticator handles it) // Get the credential using native WebAuthn API (virtual authenticator handles it)
const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential
if (!credential) { if (!credential) {
throw new Error('Failed to get credential') throw new Error('Failed to get credential')
} }
const response = credential.response as AuthenticatorAssertionResponse const response = credential.response as AuthenticatorAssertionResponse
// Convert response to JSON format expected by server // Convert response to JSON format expected by server
const authenticationResponse = { const authenticationResponse = {
id: credential.id, id: credential.id,
@@ -230,18 +230,18 @@ export async function authenticatePasskey(
clientExtensionResults: credential.getClientExtensionResults(), clientExtensionResults: credential.getClientExtensionResults(),
authenticatorAttachment: (credential as any).authenticatorAttachment, authenticatorAttachment: (credential as any).authenticatorAttachment,
} }
ws.send(JSON.stringify(authenticationResponse)) ws.send(JSON.stringify(authenticationResponse))
} catch (error: any) { } catch (error: any) {
ws.close() ws.close()
reject(new Error(error.message || 'Authentication failed')) reject(new Error(error.message || 'Authentication failed'))
} }
} }
ws.onerror = () => { ws.onerror = () => {
reject(new Error('WebSocket error during authentication')) reject(new Error('WebSocket error during authentication'))
} }
ws.onclose = (event) => { ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) { if (!event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
+3 -3
View File
@@ -27,7 +27,7 @@ export async function createVirtualAuthenticator(
options: VirtualAuthenticatorOptions = {} options: VirtualAuthenticatorOptions = {}
): Promise<VirtualAuthenticator> { ): Promise<VirtualAuthenticator> {
const cdpSession = await page.context().newCDPSession(page) const cdpSession = await page.context().newCDPSession(page)
// Enable WebAuthn in CDP // Enable WebAuthn in CDP
await cdpSession.send('WebAuthn.enable', { await cdpSession.send('WebAuthn.enable', {
enableUI: false, // Suppress any UI prompts enableUI: false, // Suppress any UI prompts
@@ -81,10 +81,10 @@ export const test = base.extend<{
virtualAuthenticator: async ({ page }, use) => { virtualAuthenticator: async ({ page }, use) => {
// Create virtual authenticator before test // Create virtual authenticator before test
const authenticator = await createVirtualAuthenticator(page) const authenticator = await createVirtualAuthenticator(page)
// Run the test // Run the test
await use(authenticator) await use(authenticator)
// Cleanup after test // Cleanup after test
await removeVirtualAuthenticator(authenticator) await removeVirtualAuthenticator(authenticator)
}, },
+18 -18
View File
@@ -15,34 +15,34 @@ interface TestState {
/** /**
* Global setup for E2E tests. * Global setup for E2E tests.
* *
* This creates a fresh test database and starts the server, * This creates a fresh test database and starts the server,
* capturing the bootstrap reset token for initial user registration. * capturing the bootstrap reset token for initial user registration.
*/ */
export default async function globalSetup() { export default async function globalSetup() {
console.log('\n🔧 Setting up E2E test environment...\n') console.log('\n🔧 Setting up E2E test environment...\n')
// Create test data directory // Create test data directory
if (!existsSync(testDataDir)) { if (!existsSync(testDataDir)) {
mkdirSync(testDataDir, { recursive: true }) mkdirSync(testDataDir, { recursive: true })
} }
// Remove old database for clean state // Remove old database for clean state
if (existsSync(dbPath)) { if (existsSync(dbPath)) {
console.log(' Removing old test database...') console.log(' Removing old test database...')
rmSync(dbPath) rmSync(dbPath)
} }
// Remove any wal/shm files too // Remove any wal/shm files too
for (const ext of ['-wal', '-shm']) { for (const ext of ['-wal', '-shm']) {
const file = dbPath + ext const file = dbPath + ext
if (existsSync(file)) rmSync(file) if (existsSync(file)) rmSync(file)
} }
console.log(' Starting server with fresh database...') console.log(' Starting server with fresh database...')
const state: TestState = {} const state: TestState = {}
// Start the server using Node's spawn // Start the server using Node's spawn
const serverProcess = spawn('uv', [ const serverProcess = spawn('uv', [
'run', 'passkey-auth', 'serve', ':4401', 'run', 'passkey-auth', 'serve', ':4401',
@@ -55,22 +55,22 @@ export default async function globalSetup() {
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}) })
state.serverPid = serverProcess.pid state.serverPid = serverProcess.pid
// Capture output to find reset token // Capture output to find reset token
const resetTokenPromise = new Promise<string>((resolve, reject) => { const resetTokenPromise = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reject(new Error('Timed out waiting for server bootstrap (30s)')) reject(new Error('Timed out waiting for server bootstrap (30s)'))
}, 30000) }, 30000)
let output = '' let output = ''
const handleData = (data: Buffer) => { const handleData = (data: Buffer) => {
const text = data.toString() const text = data.toString()
output += text output += text
process.stdout.write(text) // Echo to console process.stdout.write(text) // Echo to console
// Look for the reset token URL in the output // 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) // 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]+)+)/) 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) setTimeout(() => resolve(match[1]), 1000)
} }
} }
serverProcess.stdout?.on('data', handleData) serverProcess.stdout?.on('data', handleData)
serverProcess.stderr?.on('data', handleData) serverProcess.stderr?.on('data', handleData)
serverProcess.on('error', (err) => { serverProcess.on('error', (err) => {
clearTimeout(timeout) clearTimeout(timeout)
reject(err) reject(err)
}) })
serverProcess.on('exit', (code) => { serverProcess.on('exit', (code) => {
if (code !== 0 && code !== null) { if (code !== 0 && code !== null) {
clearTimeout(timeout) clearTimeout(timeout)
@@ -96,7 +96,7 @@ export default async function globalSetup() {
} }
}) })
}) })
try { try {
state.resetToken = await resetTokenPromise state.resetToken = await resetTokenPromise
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`) console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
@@ -105,9 +105,9 @@ export default async function globalSetup() {
serverProcess.kill() serverProcess.kill()
throw err throw err
} }
// Save state for tests // Save state for tests
writeFileSync(stateFile, JSON.stringify(state, null, 2)) writeFileSync(stateFile, JSON.stringify(state, null, 2))
console.log(' ✅ E2E test environment ready\n') console.log(' ✅ E2E test environment ready\n')
} }
+6 -6
View File
@@ -13,17 +13,17 @@ interface TestState {
/** /**
* Global teardown for E2E tests. * Global teardown for E2E tests.
* *
* This cleans up the test server and optionally removes the test database. * This cleans up the test server and optionally removes the test database.
*/ */
export default async function globalTeardown() { export default async function globalTeardown() {
console.log('\n🧹 Cleaning up E2E test environment...\n') console.log('\n🧹 Cleaning up E2E test environment...\n')
// Read state file to get server PID // Read state file to get server PID
if (existsSync(stateFile)) { if (existsSync(stateFile)) {
try { try {
const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8')) const state: TestState = JSON.parse(readFileSync(stateFile, 'utf-8'))
if (state.serverPid) { if (state.serverPid) {
console.log(` Stopping server (PID: ${state.serverPid})...`) console.log(` Stopping server (PID: ${state.serverPid})...`)
try { try {
@@ -40,11 +40,11 @@ export default async function globalTeardown() {
} catch (err) { } catch (err) {
console.warn(' Warning: Could not read state file') console.warn(' Warning: Could not read state file')
} }
// Clean up state file // Clean up state file
rmSync(stateFile, { force: true }) rmSync(stateFile, { force: true })
} }
// Optionally clean up test database (keep it for debugging by default) // Optionally clean up test database (keep it for debugging by default)
if (process.env.CLEANUP_TEST_DB === 'true') { if (process.env.CLEANUP_TEST_DB === 'true') {
const dbPath = join(testDataDir, 'test.sqlite') const dbPath = join(testDataDir, 'test.sqlite')
@@ -58,6 +58,6 @@ export default async function globalTeardown() {
if (existsSync(file)) rmSync(file) if (existsSync(file)) rmSync(file)
} }
} }
console.log(' ✅ Cleanup complete\n') console.log(' ✅ Cleanup complete\n')
} }
+20 -20
View File
@@ -11,21 +11,21 @@ import {
/** /**
* E2E tests for PasskeyAuth using Chrome's Virtual Authenticator. * E2E tests for PasskeyAuth using Chrome's Virtual Authenticator.
* *
* These tests exercise the complete WebAuthn flow: * These tests exercise the complete WebAuthn flow:
* 1. Registration via WebSocket using bootstrap reset token * 1. Registration via WebSocket using bootstrap reset token
* 2. Authentication via WebSocket * 2. Authentication via WebSocket
* 3. Session validation * 3. Session validation
* 4. User info retrieval * 4. User info retrieval
* 5. Logout * 5. Logout
* *
* The virtual authenticator simulates a hardware passkey device, * The virtual authenticator simulates a hardware passkey device,
* allowing fully automated testing without physical hardware. * allowing fully automated testing without physical hardware.
*/ */
test.describe('Passkey Authentication E2E', () => { test.describe('Passkey Authentication E2E', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401' const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
// Shared state across tests in this describe block // 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 // Navigate to auth page to establish origin for WebAuthn
await page.goto('/auth/') await page.goto('/auth/')
await expect(page).toHaveTitle(/.*/) await expect(page).toHaveTitle(/.*/)
// Page should load - 401 errors are expected since user is not logged in // Page should load - 401 errors are expected since user is not logged in
await page.waitForTimeout(500) await page.waitForTimeout(500)
// Just verify the page loaded without JS errors (network 401s are OK) // Just verify the page loaded without JS errors (network 401s are OK)
console.log('✓ Auth page loaded successfully') console.log('✓ Auth page loaded successfully')
}) })
test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => { test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => {
test.skip(!resetToken, 'No reset token available from bootstrap') test.skip(!resetToken, 'No reset token available from bootstrap')
// Must visit the page first to establish origin // Must visit the page first to establish origin
await page.goto('/auth/') await page.goto('/auth/')
// Perform registration via WebSocket with virtual authenticator // Perform registration via WebSocket with virtual authenticator
// Using the bootstrap reset token for the admin user // Using the bootstrap reset token for the admin user
const result = await registerPasskey(page, baseUrl, { const result = await registerPasskey(page, baseUrl, {
@@ -91,10 +91,10 @@ test.describe('Passkey Authentication E2E', () => {
test.skip(!sessionToken, 'Requires successful registration') test.skip(!sessionToken, 'Requires successful registration')
const validation = await validateSession(page, baseUrl, sessionToken) const validation = await validateSession(page, baseUrl, sessionToken)
expect(validation.valid).toBe(true) expect(validation.valid).toBe(true)
expect(validation.user_uuid).toBe(userUuid) expect(validation.user_uuid).toBe(userUuid)
console.log(`✓ Session validated for user: ${validation.user_uuid}`) console.log(`✓ Session validated for user: ${validation.user_uuid}`)
}) })
@@ -102,12 +102,12 @@ test.describe('Passkey Authentication E2E', () => {
test.skip(!sessionToken, 'Requires successful registration') test.skip(!sessionToken, 'Requires successful registration')
const userInfo = await getUserInfo(page, baseUrl, sessionToken) const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.user.user_uuid).toBe(userUuid) expect(userInfo.user.user_uuid).toBe(userUuid)
expect(userInfo.user.user_name).toBe('Admin User') expect(userInfo.user.user_name).toBe('Admin User')
expect(userInfo.credentials).toBeDefined() expect(userInfo.credentials).toBeDefined()
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1) expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`) console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
console.log(`✓ Credentials count: ${userInfo.credentials.length}`) console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
}) })
@@ -128,9 +128,9 @@ test.describe('Passkey Authentication E2E', () => {
resetToken: deviceLink.token, resetToken: deviceLink.token,
displayName: 'Admin User (test device)' displayName: 'Admin User (test device)'
}) })
console.log(`✓ Added test credential: ${regResult.credential_uuid}`) console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
// Now logout and authenticate with the fresh credential // Now logout and authenticate with the fresh credential
await logout(page, baseUrl, regResult.session_token) await logout(page, baseUrl, regResult.session_token)
console.log('✓ Logged out') console.log('✓ Logged out')
@@ -153,10 +153,10 @@ test.describe('Passkey Authentication E2E', () => {
test.skip(!sessionToken, 'Requires successful authentication') test.skip(!sessionToken, 'Requires successful authentication')
const validation = await validateSession(page, baseUrl, sessionToken) const validation = await validateSession(page, baseUrl, sessionToken)
expect(validation.valid).toBe(true) expect(validation.valid).toBe(true)
expect(validation.user_uuid).toBe(userUuid) expect(validation.user_uuid).toBe(userUuid)
console.log(`✓ New session validated`) console.log(`✓ New session validated`)
}) })
@@ -164,7 +164,7 @@ test.describe('Passkey Authentication E2E', () => {
test.skip(!sessionToken, 'Requires valid session') test.skip(!sessionToken, 'Requires valid session')
await logout(page, baseUrl, sessionToken) await logout(page, baseUrl, sessionToken)
// Session should no longer be valid // Session should no longer be valid
const response = await page.request.post(`${baseUrl}/auth/api/validate`, { const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: { headers: {
@@ -172,7 +172,7 @@ test.describe('Passkey Authentication E2E', () => {
}, },
failOnStatusCode: false, failOnStatusCode: false,
}) })
expect(response.status()).toBe(401) expect(response.status()).toBe(401)
console.log(`✓ Logout successful, session invalidated`) console.log(`✓ Logout successful, session invalidated`)
}) })
@@ -188,7 +188,7 @@ test.describe('Session Management', () => {
}, },
failOnStatusCode: false, failOnStatusCode: false,
}) })
// Server may return 400 (bad format) or 401 (unauthorized) // Server may return 400 (bad format) or 401 (unauthorized)
expect([400, 401]).toContain(response.status()) expect([400, 401]).toContain(response.status())
console.log(`✓ Invalid token correctly rejected`) console.log(`✓ Invalid token correctly rejected`)
@@ -198,7 +198,7 @@ test.describe('Session Management', () => {
const response = await page.request.post(`${baseUrl}/auth/api/validate`, { const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
failOnStatusCode: false, failOnStatusCode: false,
}) })
expect(response.status()).toBe(401) expect(response.status()).toBe(401)
console.log(`✓ Missing token correctly rejected`) console.log(`✓ Missing token correctly rejected`)
}) })