636 lines
23 KiB
TypeScript
636 lines
23 KiB
TypeScript
import { test, expect, createVirtualAuthenticator } from './fixtures/virtual-authenticator'
|
||
import {
|
||
registerPasskey,
|
||
authenticatePasskey,
|
||
validateSession,
|
||
getUserInfo,
|
||
logout,
|
||
getBootstrapResetToken,
|
||
createDeviceLink,
|
||
getSessionCookieName,
|
||
saveSessionToken,
|
||
getSavedSessionToken,
|
||
saveDeviceTokens,
|
||
} 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.
|
||
*
|
||
* These tests exercise the complete WebAuthn flow:
|
||
* 1. Registration via WebSocket using bootstrap reset token
|
||
* 2. Authentication via WebSocket
|
||
* 3. Session validation
|
||
* 4. User info retrieval
|
||
* 5. Logout
|
||
*
|
||
* The virtual authenticator simulates a hardware passkey device,
|
||
* allowing fully automated testing without physical hardware.
|
||
*/
|
||
|
||
test.describe('Passkey Authentication E2E', () => {
|
||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||
|
||
test.describe.configure({ mode: 'serial' })
|
||
|
||
// Shared state across tests in this describe block
|
||
let sessionToken: string
|
||
let userUuid: string
|
||
let credentialUuid: string
|
||
let resetToken: string | undefined
|
||
|
||
test.beforeAll(() => {
|
||
// Get the bootstrap reset token from global setup
|
||
resetToken = getBootstrapResetToken()
|
||
if (!resetToken) {
|
||
console.warn('⚠️ No reset token found - registration test may fail')
|
||
} else {
|
||
console.log(`📝 Using reset token: ${resetToken}`)
|
||
}
|
||
})
|
||
|
||
test('should load the auth page', async ({ page }) => {
|
||
// Navigate to auth page to establish origin for WebAuthn
|
||
await page.goto('/auth/')
|
||
await expect(page).toHaveTitle(/.*/)
|
||
|
||
// Page should load - 401 errors are expected since user is not logged in
|
||
await page.waitForTimeout(500)
|
||
|
||
// Take screenshot of the login view
|
||
await page.screenshot({ path: 'test-results/login-view.png' })
|
||
console.log('✓ Screenshot saved: test-results/login-view.png')
|
||
|
||
// Just verify the page loaded without JS errors (network 401s are OK)
|
||
console.log('✓ Auth page loaded successfully')
|
||
})
|
||
|
||
test('should register admin passkey via WebSocket using reset token', async ({ page, virtualAuthenticator }) => {
|
||
test.skip(!resetToken, 'No reset token available from bootstrap')
|
||
|
||
// Must visit the page first to establish origin
|
||
await page.goto('/auth/')
|
||
|
||
// Perform registration via WebSocket with virtual authenticator
|
||
// Using the bootstrap reset token for the admin user
|
||
const result = await registerPasskey(page, baseUrl, {
|
||
resetToken: resetToken,
|
||
displayName: 'Admin User',
|
||
})
|
||
|
||
// Verify registration result
|
||
expect(result.session_token).toBeDefined()
|
||
expect(result.session_token).toHaveLength(16)
|
||
expect(result.user_uuid).toBeDefined()
|
||
expect(result.credential_uuid).toBeDefined()
|
||
expect(result.message).toContain('successfully')
|
||
|
||
// Store for subsequent tests
|
||
sessionToken = result.session_token
|
||
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)}...`)
|
||
})
|
||
|
||
test('should create device tokens for other tests', async ({ page }) => {
|
||
test.skip(!sessionToken, 'Requires successful registration')
|
||
|
||
// Create a batch of device tokens for API tests to use
|
||
// Each API test needs its own token to register a passkey in its virtual authenticator
|
||
const tokenCount = 15 // Enough for all API tests
|
||
const tokens: string[] = []
|
||
|
||
for (let i = 0; i < tokenCount; i++) {
|
||
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
|
||
tokens.push(deviceLink.token)
|
||
}
|
||
|
||
saveDeviceTokens(tokens)
|
||
console.log(`✓ Created ${tokens.length} device tokens for API tests`)
|
||
})
|
||
|
||
test('should validate the session token', async ({ page }) => {
|
||
// Skip if registration didn't run
|
||
test.skip(!sessionToken, 'Requires successful registration')
|
||
|
||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||
|
||
expect(validation.valid).toBe(true)
|
||
expect(validation.user_uuid).toBe(userUuid)
|
||
|
||
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
|
||
})
|
||
|
||
test('should retrieve user info', async ({ page }) => {
|
||
test.skip(!sessionToken, 'Requires successful registration')
|
||
|
||
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
||
|
||
expect(userInfo.user.user_uuid).toBe(userUuid)
|
||
expect(userInfo.user.user_name).toBe('Admin User')
|
||
expect(userInfo.credentials).toBeDefined()
|
||
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
|
||
|
||
// Navigate to profile and take screenshot
|
||
const cookieName = getSessionCookieName()
|
||
await page.context().addCookies([{
|
||
name: cookieName,
|
||
value: sessionToken,
|
||
domain: 'localhost',
|
||
path: '/',
|
||
secure: true,
|
||
httpOnly: true,
|
||
sameSite: 'Strict' as const,
|
||
}])
|
||
await page.goto('/auth/')
|
||
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
||
await page.screenshot({ path: 'test-results/profile-view.png' })
|
||
console.log('✓ Screenshot saved: test-results/profile-view.png')
|
||
|
||
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
|
||
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
|
||
})
|
||
|
||
test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => {
|
||
test.skip(!sessionToken, 'Requires successful registration')
|
||
|
||
// Navigate to page (required for WebAuthn origin)
|
||
await page.goto('/auth/')
|
||
|
||
// The virtual authenticator in this context is new and doesn't have credentials.
|
||
// Create a device link using the current session, then register a new credential.
|
||
const deviceLink = await createDeviceLink(page, baseUrl, sessionToken)
|
||
console.log(`✓ Created device link with token: ${deviceLink.token}`)
|
||
|
||
// Register a new credential using the device link
|
||
const regResult = await registerPasskey(page, baseUrl, {
|
||
resetToken: deviceLink.token,
|
||
displayName: 'Admin User (test device)'
|
||
})
|
||
|
||
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
|
||
|
||
// Now logout and authenticate with the fresh credential
|
||
await logout(page, baseUrl, regResult.session_token)
|
||
console.log('✓ Logged out')
|
||
|
||
// Authenticate with the virtual authenticator (now has a valid credential)
|
||
const result = await authenticatePasskey(page, baseUrl)
|
||
|
||
expect(result.session_token).toBeDefined()
|
||
expect(result.session_token).toHaveLength(16)
|
||
expect(result.user_uuid).toBe(userUuid)
|
||
|
||
// 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)}...`)
|
||
})
|
||
|
||
test('should validate new session after authentication', async ({ page }) => {
|
||
test.skip(!sessionToken, 'Requires successful authentication')
|
||
|
||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||
|
||
expect(validation.valid).toBe(true)
|
||
expect(validation.user_uuid).toBe(userUuid)
|
||
|
||
console.log(`✓ New session validated`)
|
||
})
|
||
|
||
// 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:4404'
|
||
|
||
test('should reject invalid session token', async ({ page }) => {
|
||
const cookieName = getSessionCookieName()
|
||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||
headers: {
|
||
'Cookie': `${cookieName}=invalid_token_123`,
|
||
},
|
||
failOnStatusCode: false,
|
||
})
|
||
|
||
// Server may return 400 (bad format) or 401 (unauthorized)
|
||
expect([400, 401]).toContain(response.status())
|
||
console.log(`✓ Invalid token correctly rejected`)
|
||
})
|
||
|
||
test('should reject missing session token', async ({ page }) => {
|
||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||
failOnStatusCode: false,
|
||
})
|
||
|
||
expect(response.status()).toBe(401)
|
||
console.log(`✓ Missing token correctly rejected`)
|
||
})
|
||
})
|
||
|
||
test.describe('Device Addition Dialog', () => {
|
||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||
|
||
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 (text strips scheme, but href has it)
|
||
const linkElement = dialog.locator('a.qr-link')
|
||
await expect(linkElement).toBeVisible()
|
||
const linkText = await linkElement.textContent()
|
||
const linkHref = await linkElement.getAttribute('href')
|
||
// Text shows hostname without scheme
|
||
expect(linkText).toContain('localhost:4404/auth/')
|
||
// Href includes full URL with scheme
|
||
expect(linkHref).toContain('http://localhost:4404/auth/')
|
||
console.log(`✓ Device link displayed: ${linkText} (href: ${linkHref})`)
|
||
|
||
// 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:4404'
|
||
|
||
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:4404'
|
||
|
||
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)`)
|
||
}
|
||
})
|
||
})
|