Fix E2E tests, all passing.

This commit is contained in:
Leo Vasanko
2026-02-17 22:46:19 +00:00
parent 04e7119ac8
commit d330d98daf
9 changed files with 149 additions and 71 deletions
+5 -5
View File
@@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => {
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
expect(userInfo.ctx.user.uuid).toBe(userUuid)
expect(userInfo.ctx.user.display_name).toBe('Admin User')
expect(userInfo.user.uuid).toBe(userUuid)
expect(userInfo.user.display_name).toBe('Admin User')
expect(userInfo.credentials).toBeDefined()
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1)
// Navigate to profile and take screenshot
const cookieName = getSessionCookieName()
@@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => {
await page.screenshot({ path: 'test-results/profile-view.png' })
console.log('✓ Screenshot saved: test-results/profile-view.png')
console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`)
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
console.log(`✓ User info retrieved: ${userInfo.user.display_name}`)
console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`)
})
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
+31 -15
View File
@@ -10,6 +10,12 @@ import {
logout,
} from './fixtures/passkey-helpers'
import type { Page, Frame } from '@playwright/test'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* E2E tests for API mode authentication flows.
@@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise<void> {
/**
* Set up the test page using the examples page directly.
* The examples page already has iframe handling - we just add a Promise wrapper.
* We route the paskia-js module request to serve from the local dist.
*/
async function setupTestHarness(page: Page): Promise<void> {
// Serve paskia.js from the local filesystem since the server doesn't serve /paskia-js/
const paskiaJsPath = join(__dirname, '..', '..', 'paskia-js', 'dist', 'paskia.js')
await page.route('**/paskia-js/dist/paskia.js', async route => {
const body = readFileSync(paskiaJsPath, 'utf-8')
await route.fulfill({
body,
contentType: 'application/javascript',
})
})
// Navigate to the examples page which already has the auth iframe handling
await page.goto(`${baseUrl}/auth/examples/`)
}
@@ -143,8 +159,8 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
* Wait for auth iframe to appear and return a reference to it.
*/
async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
await page.waitForSelector('#auth-iframe', { timeout })
const iframe = page.frameLocator('#auth-iframe')
await page.waitForSelector('#paskia-iframe', { timeout })
const iframe = page.frameLocator('#paskia-iframe')
// Wait for iframe content to load
await iframe.locator('.view-root').waitFor({ timeout })
return page.frame({ url: /\/auth\/restricted\// })!
@@ -154,14 +170,14 @@ async function waitForAuthIframe(page: Page, timeout = 5000): Promise<Frame> {
* Wait for auth iframe to disappear.
*/
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
await page.waitForSelector('#auth-iframe', { state: 'detached', timeout })
await page.waitForSelector('#paskia-iframe', { state: 'detached', timeout })
}
/**
* Click Back button in auth iframe.
*/
async function clickBackInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Back' }).click()
}
@@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise<void> {
* Click Login button in auth iframe.
*/
async function clickLoginInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Login' }).click()
}
@@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise<void> {
* Click Verify button in auth iframe (for reauth mode).
*/
async function clickVerifyInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Verify' }).click()
}
@@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise<void> {
* Click Logout button in auth iframe (for forbidden mode).
*/
async function clickLogoutInIframe(page: Page): Promise<void> {
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await iframe.getByRole('button', { name: 'Logout' }).click()
}
@@ -204,7 +220,7 @@ test.describe('API Mode - 401 Login Flow', () => {
console.log('✓ Auth iframe appeared on 401')
// Verify it's in login mode (not reauth)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('🔐')
await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible()
@@ -268,7 +284,7 @@ test.describe('API Mode - 401 Login Flow', () => {
// Wait for API call to complete and verify result
const result = await apiCallPromise
expect(result.status).toBe(200)
expect(result.data.ctx).toBeDefined()
expect(result.data.user).toBeDefined()
console.log('✓ API call succeeded after authentication')
// Save the session for other tests
@@ -314,7 +330,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
console.log('✓ Reauth iframe appeared (session older than max_age)')
// Verify it's in reauth mode
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible()
@@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => {
await waitForAuthIframe(page)
console.log('✓ Reauth iframe appeared')
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await expect(iframe.locator('h1')).toContainText('Additional Authentication')
// Click Verify - virtual authenticator handles passkey
@@ -394,7 +410,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
// Check if auth iframe appeared
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
if (!iframeAppeared) {
// User might already have admin permission
@@ -410,7 +426,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
// Wait for view to stabilize and check mode
await page.waitForTimeout(500)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
const headingText = await iframe.locator('h1').textContent()
console.log(` Heading: ${headingText}`)
@@ -459,7 +475,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e)
// Check if auth iframe appeared
const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false)
if (!iframeAppeared) {
const result = await apiCallPromise
@@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => {
}
await waitForAuthIframe(page)
const iframe = page.frameLocator('#auth-iframe')
const iframe = page.frameLocator('#paskia-iframe')
await page.waitForTimeout(500)
const headingText = await iframe.locator('h1').textContent()
+60 -10
View File
@@ -193,7 +193,8 @@ export async function registerPasskey(
baseUrl: string,
options: { resetToken?: string; displayName?: string } = {}
): Promise<RegistrationResult> {
return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
// Step 1: Do WebSocket registration + exchange code in browser context
const wsResult = 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[] = []
@@ -203,6 +204,7 @@ export async function registerPasskey(
return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => {
console.log('WebSocket connected for registration')
@@ -213,15 +215,31 @@ export async function registerPasskey(
// Check for error response
if (data.detail) {
done = true
ws.close()
reject(new Error(data.detail))
return
}
// Check if this is the final success response
if (data.session_token) {
// Check if this is the final success response (exchange_code flow)
if (data.exchange_code) {
done = true
ws.close()
resolve(data)
// Exchange the code for a session cookie
try {
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
})
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
resolve({
user: data.user,
credential: data.credential,
message: data.message || 'Registration successful',
})
} catch (err: any) {
reject(new Error(`Code exchange failed: ${err.message}`))
}
return
}
@@ -293,12 +311,21 @@ export async function registerPasskey(
}
ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) {
if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
}
}
})
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
// Step 2: Extract the session token from the cookie set by the exchange
const cookies = await page.context().cookies()
const cookieName = getSessionCookieName()
const sessionCookie = cookies.find(c => c.name === cookieName)
return {
...wsResult,
session_token: sessionCookie?.value || '',
}
}
/**
@@ -309,11 +336,13 @@ export async function authenticatePasskey(
page: Page,
baseUrl: string
): Promise<AuthenticationResult> {
return await page.evaluate(async ({ baseUrl }) => {
// Step 1: Do WebSocket authentication + exchange code in browser context
const wsResult = await page.evaluate(async ({ baseUrl }) => {
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
return new Promise<any>((resolve, reject) => {
const ws = new WebSocket(wsUrl)
let done = false
ws.onopen = () => {
console.log('WebSocket connected for authentication')
@@ -324,15 +353,27 @@ export async function authenticatePasskey(
// Check for error response
if (data.detail) {
done = true
ws.close()
reject(new Error(data.detail))
return
}
// Check if this is the final success response
if (data.session_token) {
// Check if this is the final success response (exchange_code flow)
if (data.exchange_code) {
done = true
ws.close()
resolve(data)
// Exchange the code for a session cookie
try {
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
})
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
resolve({ user: data.user })
} catch (err: any) {
reject(new Error(`Code exchange failed: ${err.message}`))
}
return
}
@@ -395,12 +436,21 @@ export async function authenticatePasskey(
}
ws.onclose = (event) => {
if (!event.wasClean && event.code !== 1000) {
if (!done && !event.wasClean && event.code !== 1000) {
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
}
}
})
}, { baseUrl })
// Step 2: Extract the session token from the cookie set by the exchange
const cookies = await page.context().cookies()
const cookieName = getSessionCookieName()
const sessionCookie = cookies.find(c => c.name === cookieName)
return {
...wsResult,
session_token: sessionCookie?.value || '',
}
}
/**
+4 -4
View File
@@ -42,16 +42,16 @@ export default async function globalSetup() {
const serverArgs = COLLECT_COVERAGE
? [
'run', 'coverage', 'run', '--parallel-mode',
'-m', 'paskia.fastapi', 'localhost:4404',
'-m', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost'
]
: [
'run', 'paskia', 'localhost:4404',
'run', 'paskia', '-l', 'localhost:4404',
'--rp-id', 'localhost'
]
// Use a temporary jsonl file for test database
const testDbFile = join(testDataDir, 'test-db.jsonl')
// Use a fresh database file for tests
const testDbFile = join(testDataDir, 'test.paskiadb')
// Start the server using Node's spawn
const serverProcess = spawn('uv', serverArgs, {
+1 -1
View File
@@ -60,7 +60,7 @@ export default async function globalTeardown() {
}
// Clean up test database
const testDbFile = join(testDataDir, 'test-db.jsonl')
const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) {
console.log(' Removing test database...')
rmSync(testDbFile)