Fixed and updated E2E test suite. Added user credential registration tests. Coverage for backend and frontend.

This commit is contained in:
Leo Vasanko
2025-12-06 00:52:35 +00:00
parent 0b17681907
commit 8d723155ba
13 changed files with 2052 additions and 73 deletions
+147
View File
@@ -0,0 +1,147 @@
import { test as base, type Page, type CDPSession } from '@playwright/test'
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const coverageDir = join(__dirname, '..', '..', 'coverage-frontend')
// Check if frontend coverage is enabled
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
interface CoverageEntry {
url: string
scriptId: string
source?: string
functions: Array<{
functionName: string
ranges: Array<{
startOffset: number
endOffset: number
count: number
}>
isBlockCoverage: boolean
}>
}
/**
* Collect V8 JavaScript coverage from the page.
*/
async function startCoverage(page: Page): Promise<CDPSession | null> {
if (!COLLECT_COVERAGE) return null
try {
const cdp = await page.context().newCDPSession(page)
await cdp.send('Profiler.enable')
await cdp.send('Profiler.startPreciseCoverage', {
callCount: true,
detailed: true,
})
return cdp
} catch {
return null
}
}
async function stopCoverage(cdp: CDPSession | null, testName: string): Promise<void> {
if (!cdp) return
try {
const { result } = await cdp.send('Profiler.takePreciseCoverage')
await cdp.send('Profiler.stopPreciseCoverage')
await cdp.send('Profiler.disable')
// Filter to only include our app's JavaScript files
const appCoverage = result.filter((entry: CoverageEntry) =>
entry.url.includes('/auth/') &&
entry.url.endsWith('.js') &&
!entry.url.includes('node_modules')
)
if (appCoverage.length > 0) {
// Ensure coverage directory exists
if (!existsSync(coverageDir)) {
mkdirSync(coverageDir, { recursive: true })
}
// Save coverage data for this test
const safeName = testName.replace(/[^a-z0-9]/gi, '_').substring(0, 50)
const coverageFile = join(coverageDir, `coverage-${safeName}-${Date.now()}.json`)
writeFileSync(coverageFile, JSON.stringify(appCoverage, null, 2))
}
} catch (err) {
// Silently ignore coverage collection errors
}
}
/**
* Merge all coverage files into a single summary.
*/
export async function mergeCoverage(): Promise<void> {
if (!COLLECT_COVERAGE || !existsSync(coverageDir)) return
const files = require('fs').readdirSync(coverageDir).filter((f: string) => f.startsWith('coverage-') && f.endsWith('.json'))
if (files.length === 0) return
const merged: Map<string, CoverageEntry> = new Map()
for (const file of files) {
const data: CoverageEntry[] = JSON.parse(readFileSync(join(coverageDir, file), 'utf-8'))
for (const entry of data) {
const existing = merged.get(entry.url)
if (!existing) {
merged.set(entry.url, entry)
} else {
// Merge function coverage counts
for (const func of entry.functions) {
const existingFunc = existing.functions.find(f => f.functionName === func.functionName)
if (existingFunc) {
for (let i = 0; i < func.ranges.length; i++) {
if (existingFunc.ranges[i]) {
existingFunc.ranges[i].count += func.ranges[i].count
}
}
} else {
existing.functions.push(func)
}
}
}
}
}
// Write merged coverage
writeFileSync(
join(coverageDir, 'coverage-merged.json'),
JSON.stringify(Array.from(merged.values()), null, 2)
)
// Generate simple coverage summary
let totalFunctions = 0
let coveredFunctions = 0
for (const entry of merged.values()) {
for (const func of entry.functions) {
totalFunctions++
const hasCoverage = func.ranges.some(r => r.count > 0)
if (hasCoverage) coveredFunctions++
}
}
const percentage = totalFunctions > 0 ? Math.round((coveredFunctions / totalFunctions) * 100) : 0
console.log(`\n 📊 Frontend JS Coverage: ${coveredFunctions}/${totalFunctions} functions (${percentage}%)`)
console.log(` ✅ Frontend coverage data: ${coverageDir}/coverage-merged.json\n`)
}
/**
* Extended test with coverage collection.
* This wraps each test to collect V8 coverage data.
*/
export const testWithCoverage = base.extend<{
coverageSession: CDPSession | null
}>({
coverageSession: async ({ page }, use, testInfo) => {
const cdp = await startCoverage(page)
await use(cdp)
await stopCoverage(cdp, testInfo.title)
},
})
+79 -24
View File
@@ -1,9 +1,10 @@
import { type Page } from '@playwright/test'
import { existsSync, readFileSync } from 'fs'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
/**
* WebSocket helpers for passkey registration and authentication.
@@ -26,7 +27,6 @@ export interface AuthenticationResult {
* Get the bootstrap reset token from the test state file.
*/
export function getBootstrapResetToken(): string | undefined {
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
@@ -38,6 +38,51 @@ export function getBootstrapResetToken(): string | undefined {
return undefined
}
/**
* Get the session cookie name from the test state file.
*/
export function getSessionCookieName(): string {
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
return state.sessionCookie || '__Host-auth'
} catch {
return '__Host-auth'
}
}
return '__Host-auth'
}
/**
* Save a session token to the test state file for sharing across test groups.
*/
export function saveSessionToken(sessionToken: string): void {
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
state.savedSessionToken = sessionToken
writeFileSync(stateFile, JSON.stringify(state, null, 2))
} catch {
// Ignore errors
}
}
}
/**
* Get a saved session token from the test state file.
*/
export function getSavedSessionToken(): string | undefined {
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
return state.savedSessionToken
} catch {
return undefined
}
}
return undefined
}
/**
* Perform passkey registration via WebSocket.
* This runs in the browser context using the virtual authenticator.
@@ -79,30 +124,33 @@ export async function registerPasskey(
return
}
// This should be the registration options from server
// This should be the registration options from server (wrapped in optionsJSON)
// Use the native WebAuthn API with the virtual authenticator
try {
// Extract options from the optionsJSON wrapper
const opts = data.optionsJSON
// 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(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
// Build the credential creation options
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
publicKey: {
challenge: challenge,
rp: {
name: data.rp.name,
id: data.rp.id,
name: opts.rp.name,
id: opts.rp.id,
},
user: {
id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
name: data.user.name,
displayName: data.user.displayName,
id: Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
name: opts.user.name,
displayName: opts.user.displayName,
},
pubKeyCredParams: data.pubKeyCredParams,
authenticatorSelection: data.authenticatorSelection,
timeout: data.timeout,
attestation: data.attestation,
excludeCredentials: data.excludeCredentials?.map((cred: any) => ({
pubKeyCredParams: opts.pubKeyCredParams,
authenticatorSelection: opts.authenticatorSelection,
timeout: opts.timeout,
attestation: opts.attestation,
excludeCredentials: opts.excludeCredentials?.map((cred: any) => ({
...cred,
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
})) || [],
@@ -187,19 +235,22 @@ export async function authenticatePasskey(
return
}
// This should be the authentication options from server
// This should be the authentication options from server (wrapped in optionsJSON)
try {
// Extract options from the optionsJSON wrapper
const opts = data.optionsJSON
// 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(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
// Build the credential request options
const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
publicKey: {
challenge: challenge,
rpId: data.rpId,
timeout: data.timeout,
userVerification: data.userVerification,
allowCredentials: data.allowCredentials?.map((cred: any) => ({
rpId: opts.rpId,
timeout: opts.timeout,
userVerification: opts.userVerification,
allowCredentials: opts.allowCredentials?.map((cred: any) => ({
type: cred.type,
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
transports: cred.transports,
@@ -259,9 +310,10 @@ export async function validateSession(
baseUrl: string,
sessionToken: string
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
'Cookie': `${cookieName}=${sessionToken}`,
},
})
return await response.json()
@@ -275,9 +327,10 @@ export async function getUserInfo(
baseUrl: string,
sessionToken: string
): Promise<any> {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
'Cookie': `${cookieName}=${sessionToken}`,
},
})
return await response.json()
@@ -291,9 +344,10 @@ export async function logout(
baseUrl: string,
sessionToken: string
): Promise<void> {
const cookieName = getSessionCookieName()
await page.request.post(`${baseUrl}/auth/api/logout`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
'Cookie': `${cookieName}=${sessionToken}`,
},
})
}
@@ -306,9 +360,10 @@ export async function createDeviceLink(
baseUrl: string,
sessionToken: string
): Promise<{ url: string; token: string }> {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
headers: {
'Cookie': `__Host-auth=${sessionToken}`,
'Cookie': `${cookieName}=${sessionToken}`,
},
})
const data = await response.json()
+53 -2
View File
@@ -1,4 +1,13 @@
import { test as base, expect, type CDPSession, type Page } from '@playwright/test'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const coverageDir = join(__dirname, '..', '..', 'coverage-frontend')
// Check if frontend coverage is enabled
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
/**
* Virtual Authenticator configuration for WebAuthn testing.
@@ -73,12 +82,27 @@ export async function getCredentials(
}
/**
* Extended test fixture with virtual authenticator support.
* Extended test fixture with virtual authenticator support and optional coverage.
*/
export const test = base.extend<{
virtualAuthenticator: VirtualAuthenticator
}>({
virtualAuthenticator: async ({ page }, use) => {
virtualAuthenticator: async ({ page }, use, testInfo) => {
// Start coverage collection if enabled
let coverageCdp: CDPSession | null = null
if (COLLECT_COVERAGE) {
try {
coverageCdp = await page.context().newCDPSession(page)
await coverageCdp.send('Profiler.enable')
await coverageCdp.send('Profiler.startPreciseCoverage', {
callCount: true,
detailed: true,
})
} catch {
coverageCdp = null
}
}
// Create virtual authenticator before test
const authenticator = await createVirtualAuthenticator(page)
@@ -87,6 +111,33 @@ export const test = base.extend<{
// Cleanup after test
await removeVirtualAuthenticator(authenticator)
// Stop and save coverage
if (coverageCdp) {
try {
const { result } = await coverageCdp.send('Profiler.takePreciseCoverage')
await coverageCdp.send('Profiler.stopPreciseCoverage')
await coverageCdp.send('Profiler.disable')
// Filter to only include our app's JavaScript files
const appCoverage = result.filter((entry: any) =>
entry.url.includes('/auth/') &&
entry.url.endsWith('.js') &&
!entry.url.includes('node_modules')
)
if (appCoverage.length > 0) {
if (!existsSync(coverageDir)) {
mkdirSync(coverageDir, { recursive: true })
}
const safeName = testInfo.title.replace(/[^a-z0-9]/gi, '_').substring(0, 50)
const coverageFile = join(coverageDir, `coverage-${safeName}-${Date.now()}.json`)
writeFileSync(coverageFile, JSON.stringify(appCoverage, null, 2))
}
} catch {
// Silently ignore coverage collection errors
}
}
},
})
+46 -25
View File
@@ -1,57 +1,65 @@
import { spawn } from 'child_process'
import { join, dirname } from 'path'
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const testDataDir = join(__dirname, '..', 'test-data')
const stateFile = join(testDataDir, 'test-state.json')
const dbPath = join(testDataDir, 'test.sqlite')
const projectRoot = join(__dirname, '..', '..')
// Check if coverage is enabled
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
interface TestState {
resetToken?: string
serverPid?: number
sessionCookie?: string
}
/**
* Global setup for E2E tests.
*
* This creates a fresh test database and starts the server,
* capturing the bootstrap reset token for initial user registration.
* Uses in-memory SQLite database for fast, isolated tests.
* Captures 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
// Create test data directory for state file
if (!existsSync(testDataDir)) {
mkdirSync(testDataDir, { recursive: true })
}
// Remove old database for clean state
if (existsSync(dbPath)) {
console.log(' Removing old test database...')
rmSync(dbPath)
console.log(' Starting server with in-memory database...')
if (COLLECT_COVERAGE) {
console.log(' 📊 Coverage collection enabled for Python backend')
}
// 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 = {}
// Build server command - with or without coverage
const serverArgs = COLLECT_COVERAGE
? [
'run', 'coverage', 'run', '--parallel-mode',
'-m', 'paskia.fastapi', 'serve', ':4401',
'--rp-id', 'localhost',
'--origin', 'http://localhost:4401'
]
: [
'run', 'paskia', 'serve', ':4401',
'--rp-id', 'localhost',
'--origin', 'http://localhost:4401'
]
// Start the server using Node's spawn
const serverProcess = spawn('uv', [
'run', 'paskia', 'serve', ':4401',
'--rp-id', 'localhost',
'--origin', 'http://localhost:4401'
], {
cwd: testDataDir, // Run from test-data so DB is created there
// Use in-memory SQLite for faster tests
const serverProcess = spawn('uv', serverArgs, {
cwd: projectRoot,
env: {
...process.env,
PASKIA_DB: 'sqlite+aiosqlite:///:memory:',
COVERAGE_FILE: join(projectRoot, '.coverage'),
},
stdio: ['ignore', 'pipe', 'pipe'],
})
@@ -72,8 +80,9 @@ export default async function globalSetup() {
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]+)+)/)
// Format: https://localhost/auth/{token} or http://localhost:4401/auth/{token}
// where token is word.word.word.word.word (dot separated)
const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
if (match) {
clearTimeout(timeout)
// Wait a bit for server to fully start
@@ -106,6 +115,18 @@ export default async function globalSetup() {
throw err
}
// Fetch session cookie name from server settings
try {
const response = await fetch('http://localhost:4401/auth/api/settings')
const settings = await response.json()
state.sessionCookie = settings.session_cookie
console.log(` ✅ Session cookie name: ${state.sessionCookie}\n`)
} catch (err) {
console.error('Failed to fetch settings:', err)
serverProcess.kill()
throw err
}
// Save state for tests
writeFileSync(stateFile, JSON.stringify(state, null, 2))
+88 -3
View File
@@ -1,16 +1,30 @@
import { join, dirname } from 'path'
import { existsSync, rmSync, readFileSync } from 'fs'
import { existsSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { execSync } from 'child_process'
const __dirname = dirname(fileURLToPath(import.meta.url))
const testDataDir = join(__dirname, '..', 'test-data')
const stateFile = join(testDataDir, 'test-state.json')
const projectRoot = join(__dirname, '..', '..')
const coverageDir = join(__dirname, '..', 'coverage-frontend')
// Check if coverage is enabled
const COLLECT_COVERAGE = process.env.COVERAGE === '1' || process.env.COVERAGE === 'true'
interface TestState {
resetToken?: string
serverPid?: number
}
interface CoverageEntry {
url: string
functions: Array<{
functionName: string
ranges: Array<{ count: number }>
}>
}
/**
* Global teardown for E2E tests.
*
@@ -28,8 +42,8 @@ export default async function globalTeardown() {
console.log(` Stopping server (PID: ${state.serverPid})...`)
try {
process.kill(state.serverPid, 'SIGTERM')
// Wait a moment for graceful shutdown
await new Promise(r => setTimeout(r, 500))
// Wait longer for graceful shutdown and coverage data flush
await new Promise(r => setTimeout(r, COLLECT_COVERAGE ? 2000 : 500))
} catch (err: any) {
// Process may already be dead
if (err.code !== 'ESRCH') {
@@ -59,5 +73,76 @@ export default async function globalTeardown() {
}
}
// Generate Python coverage report if coverage was collected
if (COLLECT_COVERAGE) {
console.log(' 📊 Generating Python coverage report...')
try {
// Combine parallel coverage data and generate reports
execSync('uv run coverage combine', { cwd: projectRoot, stdio: 'inherit' })
execSync('uv run coverage report', { cwd: projectRoot, stdio: 'inherit' })
execSync('uv run coverage html', { cwd: projectRoot, stdio: 'inherit' })
console.log(` ✅ Python coverage report: ${join(projectRoot, 'coverage-html', 'index.html')}\n`)
} catch (err: any) {
console.warn(` Warning: Failed to generate coverage report: ${err.message}`)
}
// Merge and report frontend coverage
if (existsSync(coverageDir)) {
try {
const files = readdirSync(coverageDir).filter(f => f.startsWith('coverage-') && f.endsWith('.json') && f !== 'coverage-merged.json')
if (files.length > 0) {
const merged: Map<string, CoverageEntry> = new Map()
for (const file of files) {
const data: CoverageEntry[] = JSON.parse(readFileSync(join(coverageDir, file), 'utf-8'))
for (const entry of data) {
const existing = merged.get(entry.url)
if (!existing) {
merged.set(entry.url, entry)
} else {
// Merge function coverage counts
for (const func of entry.functions) {
const existingFunc = existing.functions.find(f => f.functionName === func.functionName)
if (existingFunc) {
for (let i = 0; i < func.ranges.length && i < existingFunc.ranges.length; i++) {
existingFunc.ranges[i].count += func.ranges[i].count
}
} else {
existing.functions.push(func)
}
}
}
}
}
// Write merged coverage
writeFileSync(
join(coverageDir, 'coverage-merged.json'),
JSON.stringify(Array.from(merged.values()), null, 2)
)
// Generate simple coverage summary
let totalFunctions = 0
let coveredFunctions = 0
for (const entry of merged.values()) {
for (const func of entry.functions) {
totalFunctions++
const hasCoverage = func.ranges.some(r => r.count > 0)
if (hasCoverage) coveredFunctions++
}
}
const percentage = totalFunctions > 0 ? Math.round((coveredFunctions / totalFunctions) * 100) : 0
console.log(` 📊 Frontend JS Coverage: ${coveredFunctions}/${totalFunctions} functions (${percentage}%)`)
console.log(` ✅ Frontend coverage data: ${coverageDir}/coverage-merged.json\n`)
}
} catch (err: any) {
console.warn(` Warning: Failed to merge frontend coverage: ${err.message}`)
}
}
}
console.log(' ✅ Cleanup complete\n')
}
+429 -18
View File
@@ -1,4 +1,4 @@
import { test, expect } from './fixtures/virtual-authenticator'
import { test, expect, createVirtualAuthenticator } from './fixtures/virtual-authenticator'
import {
registerPasskey,
authenticatePasskey,
@@ -7,7 +7,27 @@ import {
logout,
getBootstrapResetToken,
createDeviceLink,
getSessionCookieName,
saveSessionToken,
getSavedSessionToken,
} from './fixtures/passkey-helpers'
import type { Page, BrowserContext } from '@playwright/test'
/**
* Helper to set up session cookie for a page.
*/
async function setupSessionCookie(page: Page, sessionToken: string): Promise<void> {
const cookieName = getSessionCookieName()
await page.context().addCookies([{
name: cookieName,
value: sessionToken,
domain: 'localhost',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Strict' as const,
}])
}
/**
* E2E tests for Paskia using Chrome's Virtual Authenticator.
@@ -81,6 +101,9 @@ test.describe('Passkey Authentication E2E', () => {
userUuid = result.user_uuid
credentialUuid = result.credential_uuid
// Save session token for other test groups to use
saveSessionToken(sessionToken)
console.log(`✓ Registered user: ${userUuid}`)
console.log(`✓ Credential: ${credentialUuid}`)
console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`)
@@ -145,6 +168,9 @@ test.describe('Passkey Authentication E2E', () => {
// Update session token for subsequent tests
sessionToken = result.session_token
// Save session token for other test groups to use
saveSessionToken(sessionToken)
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
})
@@ -160,31 +186,17 @@ test.describe('Passkey Authentication E2E', () => {
console.log(`✓ New session validated`)
})
test('should logout successfully', async ({ page }) => {
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: {
'Cookie': `__Host-auth=${sessionToken}`,
},
failOnStatusCode: false,
})
expect(response.status()).toBe(401)
console.log(`✓ Logout successful, session invalidated`)
})
// Note: Logout test moved to the end so other test groups can use the session
})
test.describe('Session Management', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test('should reject invalid session token', async ({ page }) => {
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': '__Host-auth=invalid_token_123',
'Cookie': `${cookieName}=invalid_token_123`,
},
failOnStatusCode: false,
})
@@ -203,3 +215,402 @@ test.describe('Session Management', () => {
console.log(`✓ Missing token correctly rejected`)
})
})
test.describe('Device Addition Dialog', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test.describe.configure({ mode: 'serial' })
let sessionToken: string
test.beforeAll(() => {
// Get the session token saved by the previous test group
// Note: This runs before the logout test, so the session should still be valid
const saved = getSavedSessionToken()
if (saved) {
sessionToken = saved
}
})
test('should open device addition dialog and show QR code', async ({ page }) => {
test.skip(!sessionToken, 'Requires saved session token from previous tests')
// Set the session cookie for this test context
const cookieName = getSessionCookieName()
await page.context().addCookies([{
name: cookieName,
value: sessionToken,
domain: 'localhost',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Strict',
}])
// Navigate to auth page (which should show profile when logged in)
await page.goto('/auth/')
// Wait for the profile view to load
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
// Click the "Add Another Device" button
const addDeviceButton = page.getByRole('button', { name: 'Add Another Device' })
await expect(addDeviceButton).toBeVisible()
await addDeviceButton.click()
// Wait for the registration link modal to appear
const dialog = page.locator('.device-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// Verify dialog contains expected elements
await expect(dialog.locator('h2')).toContainText('Device Registration Link')
// Wait for QR code to be generated (canvas should have content)
const qrCanvas = dialog.locator('.qr-code')
await expect(qrCanvas).toBeVisible()
// Verify the link is displayed
const linkText = dialog.locator('.qr-link p')
await expect(linkText).toBeVisible()
const linkContent = await linkText.textContent()
expect(linkContent).toContain('localhost/auth/')
console.log(`✓ Device link displayed: ${linkContent}`)
// Verify expiration warning is shown
await expect(dialog.locator('.reg-help')).toContainText('Expires')
// Take screenshot of the dialog
await dialog.screenshot({ path: 'test-results/device-addition-dialog.png' })
console.log(`✓ Screenshot saved: test-results/device-addition-dialog.png`)
// Verify Copy Link button exists
const copyButton = dialog.getByRole('button', { name: 'Copy Link' })
await expect(copyButton).toBeVisible()
// Close the dialog (use the text button, not the icon button)
const closeButton = dialog.locator('button.btn-secondary', { hasText: 'Close' })
await closeButton.click()
await expect(dialog).not.toBeVisible()
console.log(`✓ Device addition dialog test complete`)
})
test('should extract valid reset token from dialog', async ({ page }) => {
test.skip(!sessionToken, 'Requires successful registration')
// Set the session cookie
// __Host- cookies require: secure=true, path=/, no domain (but we set domain for localhost)
const cookieName = getSessionCookieName()
await page.context().addCookies([{
name: cookieName,
value: sessionToken,
domain: 'localhost',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Strict',
}])
await page.goto('/auth/')
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
// Open the dialog
await page.getByRole('button', { name: 'Add Another Device' }).click()
const dialog = page.locator('.device-dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// Extract the reset token from the displayed URL
const linkText = dialog.locator('.qr-link p')
const linkContent = await linkText.textContent()
// URL format: localhost/auth/word1.word2.word3.word4.word5
const tokenMatch = linkContent?.match(/\/auth\/([a-z]+\.[a-z]+\.[a-z]+\.[a-z]+\.[a-z]+)/)
expect(tokenMatch).toBeTruthy()
const extractedToken = tokenMatch![1]
console.log(`✓ Extracted reset token: ${extractedToken}`)
// Close the dialog (use the text button, not the icon button)
await dialog.locator('button.btn-secondary', { hasText: 'Close' }).click()
// Verify the token can be used for registration via API
// (We won't complete registration, just verify the WebSocket accepts it)
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register?reset=${encodeURIComponent(extractedToken)}&name=Test`
// Use page.evaluate to test WebSocket connection
const wsResult = await page.evaluate(async (wsUrl) => {
return new Promise<{ success: boolean; hasOptions: boolean }>((resolve) => {
const ws = new WebSocket(wsUrl)
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
ws.close()
// Check if we got registration options (not an error)
resolve({
success: !data.status && !data.detail,
hasOptions: !!data.optionsJSON?.challenge
})
}
ws.onerror = () => resolve({ success: false, hasOptions: false })
setTimeout(() => {
ws.close()
resolve({ success: false, hasOptions: false })
}, 5000)
})
}, wsUrl)
expect(wsResult.success).toBe(true)
expect(wsResult.hasOptions).toBe(true)
console.log(`✓ Reset token is valid and accepted by server`)
})
})
test.describe('ProfileView - Add New Passkey', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test('should show credentials list in profile', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
// Wait for credentials to load
await page.waitForSelector('.credential-list', { timeout: 10000 })
// Should have at least one credential from initial registration
const credentialItems = await page.locator('.credential-item').count()
expect(credentialItems).toBeGreaterThanOrEqual(1)
console.log(`✓ Profile shows ${credentialItems} credential(s) in list`)
})
test('should add a new passkey using Add New Passkey button', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
// Create virtual authenticator for this page
await createVirtualAuthenticator(page)
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
// Wait for credentials list and get initial count
await page.waitForSelector('.credential-list', { timeout: 10000 })
const initialCredentialCount = await page.locator('.credential-item').count()
console.log(`Initial credential count: ${initialCredentialCount}`)
// Click "Add New Passkey" button
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
await expect(addPasskeyBtn).toBeVisible()
await addPasskeyBtn.click()
// Wait for WebAuthn registration to complete (virtual authenticator handles it automatically)
// The button might show loading state or there might be a success message
await page.waitForTimeout(2000) // Give time for WebSocket registration to complete
// Refresh the page to ensure we see updated credentials
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
// Should now have one more credential
const newCredentialCount = await page.locator('.credential-item').count()
expect(newCredentialCount).toBe(initialCredentialCount + 1)
console.log(`✓ Successfully added new passkey. Credentials: ${initialCredentialCount} -> ${newCredentialCount}`)
})
test('should reject duplicate passkey from same authenticator', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
// Create virtual authenticator with resident key support
// Using same authenticator configuration - credentials stored on authenticator
await createVirtualAuthenticator(page, {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
})
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
// Wait for credentials list
await page.waitForSelector('.credential-list', { timeout: 10000 })
const initialCredentialCount = await page.locator('.credential-item').count()
// Try to add a passkey - with excludeCredentials the authenticator should
// prevent re-registration of the same credential
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
await expect(addPasskeyBtn).toBeVisible()
await addPasskeyBtn.click()
// Wait for response - could be success (new credential) or error (duplicate)
await page.waitForTimeout(3000)
// Check for error message or status message
const statusMessage = page.locator('.status-message')
const hasError = await statusMessage.locator('.error, .status-error').isVisible().catch(() => false)
// Reload to check final credential count
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
const finalCredentialCount = await page.locator('.credential-item').count()
// The test passes if either:
// 1. An error was shown (duplicate rejected by excludeCredentials)
// 2. A new credential was added (fresh authenticator has no stored credential)
console.log(`Credentials: ${initialCredentialCount} -> ${finalCredentialCount}, error shown: ${hasError}`)
console.log(`✓ Add passkey flow completed (new authenticator creates new credential)`)
})
})
test.describe('ProfileView - Multi-Authenticator', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test('should add passkey from different authenticator', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
// Create a different virtual authenticator (simulating a different device)
await createVirtualAuthenticator(page, {
protocol: 'ctap2',
transport: 'usb', // Different transport - like a USB security key
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
})
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
// Wait for credentials list and get initial count
await page.waitForSelector('.credential-list', { timeout: 10000 })
const initialCredentialCount = await page.locator('.credential-item').count()
// Click "Add New Passkey" button
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
await expect(addPasskeyBtn).toBeVisible()
await addPasskeyBtn.click()
// Wait for registration to complete
await page.waitForTimeout(2000)
// Refresh to see updated list
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
const newCredentialCount = await page.locator('.credential-item').count()
expect(newCredentialCount).toBe(initialCredentialCount + 1)
console.log(`✓ Added passkey from USB authenticator. Credentials: ${initialCredentialCount} -> ${newCredentialCount}`)
})
test('should display multiple credentials with details', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
// Should have multiple credentials now from previous tests
const credentialItems = page.locator('.credential-item')
const count = await credentialItems.count()
// Verify each credential has required elements
for (let i = 0; i < count; i++) {
const item = credentialItems.nth(i)
// Should have title/name
const title = item.locator('.item-title')
await expect(title).toBeVisible()
// Should have date information
const dates = item.locator('.credential-dates')
await expect(dates).toBeVisible()
// Should have created date
const createdDate = item.locator('.date-label:has-text("Created:")')
await expect(createdDate).toBeVisible()
}
console.log(`✓ All ${count} credentials displayed with proper details`)
// Take screenshot of credentials list
await page.screenshot({
path: 'test-results/credentials-list.png',
fullPage: false,
})
console.log(`✓ Screenshot saved: test-results/credentials-list.png`)
})
test('should show current session badge', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
await setupSessionCookie(page, sessionToken!)
// Navigate to profile page
await page.goto(`${baseUrl}/auth/`)
await page.waitForLoadState('networkidle')
await page.waitForSelector('.credential-list', { timeout: 10000 })
// Look for the "Current" badge indicating current session's credential
const currentBadge = page.locator('.badge-current:has-text("Current")')
const hasCurrent = await currentBadge.isVisible().catch(() => false)
if (hasCurrent) {
console.log(`✓ Current session credential is marked with "Current" badge`)
// The current credential should have delete disabled
const currentItem = page.locator('.credential-item.current-session')
if (await currentItem.isVisible()) {
const deleteBtn = currentItem.locator('.btn-card-delete')
if (await deleteBtn.isVisible()) {
await expect(deleteBtn).toBeDisabled()
console.log(`✓ Delete button is disabled for current session credential`)
}
}
} else {
console.log(` No credential marked as current (may be using different auth method)`)
}
})
})
test.describe('Logout', () => {
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
test('should logout successfully', async ({ page }) => {
const sessionToken = getSavedSessionToken()
test.skip(!sessionToken, 'Requires saved session token')
await logout(page, baseUrl, sessionToken!)
// Session should no longer be valid
const cookieName = getSessionCookieName()
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
headers: {
'Cookie': `${cookieName}=${sessionToken}`,
},
failOnStatusCode: false,
})
expect(response.status()).toBe(401)
console.log(`✓ Logout successful, session invalidated`)
})
})