Updated E2E tests.

This commit is contained in:
Leo Vasanko
2025-12-04 04:44:58 +00:00
parent 8011a0d910
commit 39c06620c4
7 changed files with 79 additions and 79 deletions
+26 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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')
}
+6 -6
View File
@@ -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
View File
@@ -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`)
})