Updated E2E tests.
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
+26
-26
@@ -57,34 +57,34 @@ export async function registerPasskey(
|
||||
|
||||
return new Promise<any>((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<any>((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}`))
|
||||
|
||||
+3
-3
@@ -27,7 +27,7 @@ export async function createVirtualAuthenticator(
|
||||
options: VirtualAuthenticatorOptions = {}
|
||||
): Promise<VirtualAuthenticator> {
|
||||
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)
|
||||
},
|
||||
|
||||
+18
-18
@@ -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<string>((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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
+20
-20
@@ -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`)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user