Fixed and updated E2E test suite. Added user credential registration tests. Coverage for backend and frontend.
This commit is contained in:
@@ -6,3 +6,5 @@ dist/
|
|||||||
paskia.sqlite
|
paskia.sqlite
|
||||||
/paskia/frontend-build
|
/paskia/frontend-build
|
||||||
/paskia/_version.py
|
/paskia/_version.py
|
||||||
|
coverage-html/
|
||||||
|
e2e/coverage-frontend/
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ This will:
|
|||||||
2. Run all E2E tests against it
|
2. Run all E2E tests against it
|
||||||
3. Clean up the server when done
|
3. Clean up the server when done
|
||||||
|
|
||||||
|
### With Coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs tests and collects coverage for both:
|
||||||
|
- **Python backend** (via `coverage.py`) - HTML report in `coverage-html/`
|
||||||
|
- **Frontend JavaScript** (via Chrome V8 coverage) - JSON data in `e2e/coverage-frontend/`
|
||||||
|
|
||||||
### Interactive Mode
|
### Interactive Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Generated
+1127
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -9,12 +9,14 @@
|
|||||||
"test:headed": "bunx playwright test --headed",
|
"test:headed": "bunx playwright test --headed",
|
||||||
"test:debug": "bunx playwright test --debug",
|
"test:debug": "bunx playwright test --debug",
|
||||||
"test:ui": "bunx playwright test --ui",
|
"test:ui": "bunx playwright test --ui",
|
||||||
|
"test:coverage": "COVERAGE=1 bunx playwright test",
|
||||||
"report": "bunx playwright show-report",
|
"report": "bunx playwright show-report",
|
||||||
"install:browsers": "bunx playwright install chromium"
|
"install:browsers": "bunx playwright install chromium"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@simplewebauthn/browser": "^13.1.2",
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
"@types/bun": "^1.3.3"
|
"@types/bun": "^1.3.3",
|
||||||
|
"c8": "^10.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright configuration for Paskia E2E tests.
|
||||||
|
* Uses Chrome's Virtual Authenticator for automated passkey testing.
|
||||||
|
*
|
||||||
|
* Run with: bun run test
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: false, // Run tests sequentially for passkey state consistency
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1, // Single worker for database state consistency
|
||||||
|
reporter: [
|
||||||
|
['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 Paskia 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',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
// Chrome-specific settings for virtual authenticator
|
||||||
|
launchOptions: {
|
||||||
|
args: [
|
||||||
|
'--enable-features=WebAuthenticationEnterpriseAttestation',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
Vendored
+147
@@ -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
@@ -1,9 +1,10 @@
|
|||||||
import { type Page } from '@playwright/test'
|
import { type Page } from '@playwright/test'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket helpers for passkey registration and authentication.
|
* WebSocket helpers for passkey registration and authentication.
|
||||||
@@ -26,7 +27,6 @@ export interface AuthenticationResult {
|
|||||||
* Get the bootstrap reset token from the test state file.
|
* Get the bootstrap reset token from the test state file.
|
||||||
*/
|
*/
|
||||||
export function getBootstrapResetToken(): string | undefined {
|
export function getBootstrapResetToken(): string | undefined {
|
||||||
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
|
||||||
if (existsSync(stateFile)) {
|
if (existsSync(stateFile)) {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
||||||
@@ -38,6 +38,51 @@ export function getBootstrapResetToken(): string | undefined {
|
|||||||
return 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.
|
* Perform passkey registration via WebSocket.
|
||||||
* This runs in the browser context using the virtual authenticator.
|
* This runs in the browser context using the virtual authenticator.
|
||||||
@@ -79,30 +124,33 @@ export async function registerPasskey(
|
|||||||
return
|
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
|
// Use the native WebAuthn API with the virtual authenticator
|
||||||
try {
|
try {
|
||||||
|
// Extract options from the optionsJSON wrapper
|
||||||
|
const opts = data.optionsJSON
|
||||||
|
|
||||||
// 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(opts.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: {
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
rp: {
|
rp: {
|
||||||
name: data.rp.name,
|
name: opts.rp.name,
|
||||||
id: data.rp.id,
|
id: opts.rp.id,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
id: Uint8Array.from(atob(data.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||||
name: data.user.name,
|
name: opts.user.name,
|
||||||
displayName: data.user.displayName,
|
displayName: opts.user.displayName,
|
||||||
},
|
},
|
||||||
pubKeyCredParams: data.pubKeyCredParams,
|
pubKeyCredParams: opts.pubKeyCredParams,
|
||||||
authenticatorSelection: data.authenticatorSelection,
|
authenticatorSelection: opts.authenticatorSelection,
|
||||||
timeout: data.timeout,
|
timeout: opts.timeout,
|
||||||
attestation: data.attestation,
|
attestation: opts.attestation,
|
||||||
excludeCredentials: data.excludeCredentials?.map((cred: any) => ({
|
excludeCredentials: opts.excludeCredentials?.map((cred: any) => ({
|
||||||
...cred,
|
...cred,
|
||||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||||
})) || [],
|
})) || [],
|
||||||
@@ -187,19 +235,22 @@ export async function authenticatePasskey(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// This should be the authentication options from server
|
// This should be the authentication options from server (wrapped in optionsJSON)
|
||||||
try {
|
try {
|
||||||
|
// Extract options from the optionsJSON wrapper
|
||||||
|
const opts = data.optionsJSON
|
||||||
|
|
||||||
// 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(opts.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: {
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
rpId: data.rpId,
|
rpId: opts.rpId,
|
||||||
timeout: data.timeout,
|
timeout: opts.timeout,
|
||||||
userVerification: data.userVerification,
|
userVerification: opts.userVerification,
|
||||||
allowCredentials: data.allowCredentials?.map((cred: any) => ({
|
allowCredentials: opts.allowCredentials?.map((cred: any) => ({
|
||||||
type: cred.type,
|
type: cred.type,
|
||||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
||||||
transports: cred.transports,
|
transports: cred.transports,
|
||||||
@@ -259,9 +310,10 @@ export async function validateSession(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `__Host-auth=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return await response.json()
|
return await response.json()
|
||||||
@@ -275,9 +327,10 @@ export async function getUserInfo(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `__Host-auth=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return await response.json()
|
return await response.json()
|
||||||
@@ -291,9 +344,10 @@ export async function logout(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
await page.request.post(`${baseUrl}/auth/api/logout`, {
|
await page.request.post(`${baseUrl}/auth/api/logout`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `__Host-auth=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -306,9 +360,10 @@ export async function createDeviceLink(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionToken: string
|
sessionToken: string
|
||||||
): Promise<{ url: string; token: string }> {
|
): Promise<{ url: string; token: string }> {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
|
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': `__Host-auth=${sessionToken}`,
|
'Cookie': `${cookieName}=${sessionToken}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
+53
-2
@@ -1,4 +1,13 @@
|
|||||||
import { test as base, expect, type CDPSession, type Page } from '@playwright/test'
|
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.
|
* 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<{
|
export const test = base.extend<{
|
||||||
virtualAuthenticator: VirtualAuthenticator
|
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
|
// Create virtual authenticator before test
|
||||||
const authenticator = await createVirtualAuthenticator(page)
|
const authenticator = await createVirtualAuthenticator(page)
|
||||||
|
|
||||||
@@ -87,6 +111,33 @@ export const test = base.extend<{
|
|||||||
|
|
||||||
// Cleanup after test
|
// Cleanup after test
|
||||||
await removeVirtualAuthenticator(authenticator)
|
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
@@ -1,57 +1,65 @@
|
|||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const testDataDir = join(__dirname, '..', 'test-data')
|
const testDataDir = join(__dirname, '..', 'test-data')
|
||||||
const stateFile = join(testDataDir, 'test-state.json')
|
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 {
|
interface TestState {
|
||||||
resetToken?: string
|
resetToken?: string
|
||||||
serverPid?: number
|
serverPid?: number
|
||||||
|
sessionCookie?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global setup for E2E tests.
|
* Global setup for E2E tests.
|
||||||
*
|
*
|
||||||
* This creates a fresh test database and starts the server,
|
* Uses in-memory SQLite database for fast, isolated tests.
|
||||||
* capturing the bootstrap reset token for initial user registration.
|
* Captures 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 for state file
|
||||||
if (!existsSync(testDataDir)) {
|
if (!existsSync(testDataDir)) {
|
||||||
mkdirSync(testDataDir, { recursive: true })
|
mkdirSync(testDataDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old database for clean state
|
console.log(' Starting server with in-memory database...')
|
||||||
if (existsSync(dbPath)) {
|
if (COLLECT_COVERAGE) {
|
||||||
console.log(' Removing old test database...')
|
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||||
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 = {}
|
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
|
// Start the server using Node's spawn
|
||||||
const serverProcess = spawn('uv', [
|
// Use in-memory SQLite for faster tests
|
||||||
'run', 'paskia', 'serve', ':4401',
|
const serverProcess = spawn('uv', serverArgs, {
|
||||||
'--rp-id', 'localhost',
|
cwd: projectRoot,
|
||||||
'--origin', 'http://localhost:4401'
|
|
||||||
], {
|
|
||||||
cwd: testDataDir, // Run from test-data so DB is created there
|
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
PASKIA_DB: 'sqlite+aiosqlite:///:memory:',
|
||||||
|
COVERAGE_FILE: join(projectRoot, '.coverage'),
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
@@ -72,8 +80,9 @@ export default async function globalSetup() {
|
|||||||
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: https://localhost/auth/{token} or http://localhost:4401/auth/{token}
|
||||||
const match = output.match(/http:\/\/localhost:\d+\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
// where token is word.word.word.word.word (dot separated)
|
||||||
|
const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||||
if (match) {
|
if (match) {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
// Wait a bit for server to fully start
|
// Wait a bit for server to fully start
|
||||||
@@ -106,6 +115,18 @@ export default async function globalSetup() {
|
|||||||
throw err
|
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
|
// Save state for tests
|
||||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, rmSync, readFileSync } from 'fs'
|
import { existsSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const testDataDir = join(__dirname, '..', 'test-data')
|
const testDataDir = join(__dirname, '..', 'test-data')
|
||||||
const stateFile = join(testDataDir, 'test-state.json')
|
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 {
|
interface TestState {
|
||||||
resetToken?: string
|
resetToken?: string
|
||||||
serverPid?: number
|
serverPid?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CoverageEntry {
|
||||||
|
url: string
|
||||||
|
functions: Array<{
|
||||||
|
functionName: string
|
||||||
|
ranges: Array<{ count: number }>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global teardown for E2E tests.
|
* Global teardown for E2E tests.
|
||||||
*
|
*
|
||||||
@@ -28,8 +42,8 @@ export default async function globalTeardown() {
|
|||||||
console.log(` Stopping server (PID: ${state.serverPid})...`)
|
console.log(` Stopping server (PID: ${state.serverPid})...`)
|
||||||
try {
|
try {
|
||||||
process.kill(state.serverPid, 'SIGTERM')
|
process.kill(state.serverPid, 'SIGTERM')
|
||||||
// Wait a moment for graceful shutdown
|
// Wait longer for graceful shutdown and coverage data flush
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, COLLECT_COVERAGE ? 2000 : 500))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Process may already be dead
|
// Process may already be dead
|
||||||
if (err.code !== 'ESRCH') {
|
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')
|
console.log(' ✅ Cleanup complete\n')
|
||||||
}
|
}
|
||||||
|
|||||||
+429
-18
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from './fixtures/virtual-authenticator'
|
import { test, expect, createVirtualAuthenticator } from './fixtures/virtual-authenticator'
|
||||||
import {
|
import {
|
||||||
registerPasskey,
|
registerPasskey,
|
||||||
authenticatePasskey,
|
authenticatePasskey,
|
||||||
@@ -7,7 +7,27 @@ import {
|
|||||||
logout,
|
logout,
|
||||||
getBootstrapResetToken,
|
getBootstrapResetToken,
|
||||||
createDeviceLink,
|
createDeviceLink,
|
||||||
|
getSessionCookieName,
|
||||||
|
saveSessionToken,
|
||||||
|
getSavedSessionToken,
|
||||||
} from './fixtures/passkey-helpers'
|
} 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.
|
* E2E tests for Paskia using Chrome's Virtual Authenticator.
|
||||||
@@ -81,6 +101,9 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
userUuid = result.user_uuid
|
userUuid = result.user_uuid
|
||||||
credentialUuid = result.credential_uuid
|
credentialUuid = result.credential_uuid
|
||||||
|
|
||||||
|
// Save session token for other test groups to use
|
||||||
|
saveSessionToken(sessionToken)
|
||||||
|
|
||||||
console.log(`✓ Registered user: ${userUuid}`)
|
console.log(`✓ Registered user: ${userUuid}`)
|
||||||
console.log(`✓ Credential: ${credentialUuid}`)
|
console.log(`✓ Credential: ${credentialUuid}`)
|
||||||
console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`)
|
console.log(`✓ Session token: ${sessionToken.substring(0, 4)}...`)
|
||||||
@@ -145,6 +168,9 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
// Update session token for subsequent tests
|
// Update session token for subsequent tests
|
||||||
sessionToken = result.session_token
|
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(`✓ Authenticated as user: ${result.user_uuid}`)
|
||||||
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
|
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
|
||||||
})
|
})
|
||||||
@@ -160,31 +186,17 @@ test.describe('Passkey Authentication E2E', () => {
|
|||||||
console.log(`✓ New session validated`)
|
console.log(`✓ New session validated`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should logout successfully', async ({ page }) => {
|
// Note: Logout test moved to the end so other test groups can use the session
|
||||||
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`)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Session Management', () => {
|
test.describe('Session Management', () => {
|
||||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
|
const baseUrl = process.env.BASE_URL || 'http://localhost:4401'
|
||||||
|
|
||||||
test('should reject invalid session token', async ({ page }) => {
|
test('should reject invalid session token', async ({ page }) => {
|
||||||
|
const cookieName = getSessionCookieName()
|
||||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': '__Host-auth=invalid_token_123',
|
'Cookie': `${cookieName}=invalid_token_123`,
|
||||||
},
|
},
|
||||||
failOnStatusCode: false,
|
failOnStatusCode: false,
|
||||||
})
|
})
|
||||||
@@ -203,3 +215,402 @@ test.describe('Session Management', () => {
|
|||||||
console.log(`✓ Missing token correctly rejected`)
|
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`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -32,8 +32,26 @@ version-file = "paskia/_version.py"
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff>=0.1.0",
|
"ruff>=0.1.0",
|
||||||
|
"coverage[toml]>=7.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["paskia"]
|
||||||
|
branch = true
|
||||||
|
parallel = true
|
||||||
|
sigterm = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
]
|
||||||
|
show_missing = true
|
||||||
|
|
||||||
|
[tool.coverage.html]
|
||||||
|
directory = "coverage-html"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py39"
|
target-version = "py39"
|
||||||
line-length = 88
|
line-length = 88
|
||||||
|
|||||||
Reference in New Issue
Block a user