diff --git a/tests/e2e/full-flow.spec.js b/tests/e2e/full-flow.spec.js index ed772ac..5d472f6 100644 --- a/tests/e2e/full-flow.spec.js +++ b/tests/e2e/full-flow.spec.js @@ -4,7 +4,7 @@ const { test, expect } = require('@playwright/test'); const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); test.describe('Full user journey', () => { - test('register via magic link, set password, logout, login', async ({ page, request }) => { + test('register via magic link, set password, logout, login', async ({ page }) => { // Verify fixtures are loaded expect(fixtures.register_token).toBeTruthy(); @@ -40,7 +40,9 @@ test.describe('Full user journey', () => { await expect(successMsg).toContainText('Password updated'); // ---- Step 3: Logout ---- - await request.post('/logout'); + // Clear the session cookie via the browser context (the request fixture + // has a separate cookie jar and cannot clear the page's session). + await page.context().clearCookies(); // Navigate to credentials — should redirect to login since we're logged out await page.goto('/manage/credentials'); diff --git a/tests/e2e/login.spec.js b/tests/e2e/login.spec.js index 393a84e..1455fa7 100644 --- a/tests/e2e/login.spec.js +++ b/tests/e2e/login.spec.js @@ -86,8 +86,8 @@ test.describe('Login page', () => { await expect(page.locator('form[hx-post="/login/password"] button[type="submit"]')).toBeVisible(); }); - test('WebAuthn login form exists', async ({ page }) => { - await expect(page.locator('#webauthn-login-form')).toHaveCount(1); + test('WebAuthn login button exists', async ({ page }) => { + await expect(page.locator('#webauthn-login-btn')).toHaveCount(1); }); }); diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 774343d..7a9f9b4 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -14,7 +14,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" PORT="${E2E_PORT:-8099}" -export TARGET_URL="http://127.0.0.1:${PORT}" +# Use "localhost" (not 127.0.0.1) so that the WebAuthn RP ID is a valid +# domain — the spec forbids IP addresses as RP IDs. +export TARGET_URL="http://localhost:${PORT}" # --- Temp directory for e2e state --- E2E_TMPDIR="$(mktemp -d)" diff --git a/tests/e2e/webauthn.spec.js b/tests/e2e/webauthn.spec.js index 70e93a6..bd7af85 100644 --- a/tests/e2e/webauthn.spec.js +++ b/tests/e2e/webauthn.spec.js @@ -49,6 +49,24 @@ async function loginWithPassword(page, username, password) { await page.waitForURL('**/manage/credentials', { timeout: 5000 }); } +/** + * Click the "Add security key" button and wait for the registration ceremony + * to complete. The JS calls window.location.reload() on success, so we wait + * for the page navigation event (not just the URL, which is already correct). + */ +async function registerPasskey(page) { + const reloadPromise = page.waitForEvent('load', { timeout: 10000 }); + await page.click('#webauthn-register-btn'); + await reloadPromise; +} + +/** + * Count the current number of credential
  • items in #webauthn-list. + */ +async function getCredentialCount(page) { + return page.locator('#webauthn-list li').count(); +} + test.describe('WebAuthn passkey flows', () => { test.describe('Registration', () => { test('register a passkey from credentials page', async ({ page }) => { @@ -57,17 +75,15 @@ test.describe('WebAuthn passkey flows', () => { ({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page)); await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); - // Before registration, page should show no security keys - await expect(page.locator('#webauthn-list')).toContainText('No security keys registered'); + const countBefore = await getCredentialCount(page); // Click the register button — the virtual authenticator handles the ceremony - await page.click('#webauthn-register-btn'); + await registerPasskey(page); - // Registration triggers window.location.reload() on success - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - - // After reload the passkey should appear in the list - await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 }); + // After reload one more passkey should appear in the list + await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, { + timeout: 5000, + }); } finally { await removeVirtualAuthenticator(cdpSession, authenticatorId); } @@ -79,16 +95,17 @@ test.describe('WebAuthn passkey flows', () => { ({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page)); await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); + const countBefore = await getCredentialCount(page); + // Register a passkey - await page.click('#webauthn-register-btn'); - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); + await registerPasskey(page); - // Exactly one credential should be listed + // One more credential should be listed const items = page.locator('#webauthn-list li'); - await expect(items).toHaveCount(1, { timeout: 5000 }); + await expect(items).toHaveCount(countBefore + 1, { timeout: 5000 }); - // The list item should be visible - await expect(items.first()).toBeVisible(); + // The last item should be visible (newly added) + await expect(items.last()).toBeVisible(); } finally { await removeVirtualAuthenticator(cdpSession, authenticatorId); } @@ -98,7 +115,6 @@ test.describe('WebAuthn passkey flows', () => { test.describe('Authentication', () => { test('full round-trip: register passkey, logout, login with passkey', async ({ page, - request, }) => { let cdpSession, authenticatorId; try { @@ -106,14 +122,16 @@ test.describe('WebAuthn passkey flows', () => { // Step 1: Log in with password and register a passkey await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); - await page.click('#webauthn-register-btn'); - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); + const countBefore = await getCredentialCount(page); + await registerPasskey(page); - // Verify the passkey is in the list - await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 }); + // Verify a new passkey was added + await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, { + timeout: 5000, + }); // Step 2: Logout - await request.post('/logout'); + await page.context().clearCookies(); // Step 3: Go to login page and authenticate with the passkey (usernameless) await page.goto('/login'); @@ -132,19 +150,21 @@ test.describe('WebAuthn passkey flows', () => { } }); - test('session established after passkey login', async ({ page, request }) => { + test('session established after passkey login', async ({ page }) => { let cdpSession, authenticatorId; try { ({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page)); // Register a passkey via password login first await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); - await page.click('#webauthn-register-btn'); - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 }); + const countBefore = await getCredentialCount(page); + await registerPasskey(page); + await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, { + timeout: 5000, + }); // Logout - await request.post('/logout'); + await page.context().clearCookies(); // Login with passkey await page.goto('/login'); @@ -162,23 +182,24 @@ test.describe('WebAuthn passkey flows', () => { }); test.describe('Deletion', () => { - test('delete a passkey via API after registration', async ({ page, request }) => { + test('delete a passkey via API after registration', async ({ page }) => { let cdpSession, authenticatorId; try { ({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page)); await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); + const countBefore = await getCredentialCount(page); + // Register a passkey - await page.click('#webauthn-register-btn'); - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 }); + await registerPasskey(page); + await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, { + timeout: 5000, + }); // The credentials template does not expose a delete button in the UI. - // Test the DELETE API endpoint directly instead. - const credResponse = await request.get('/manage/credentials'); - const html = await credResponse.text(); + // Verify registration was successful by checking the page content directly. + const html = await page.content(); - // Extract the credential ID from the page HTML (data attribute or link) // The template renders
  • items; the API endpoint is DELETE /manage/credentials/webauthn/{id} // Since the template doesn't include IDs in the HTML, verify registration was successful // and note that the delete UI is not yet wired up in the template. @@ -212,19 +233,21 @@ test.describe('WebAuthn passkey flows', () => { await expect(status).not.toBeEmpty(); }); - test('server error on authentication complete shows error', async ({ page, request }) => { + test('server error on authentication complete shows error', async ({ page }) => { let cdpSession, authenticatorId; try { ({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page)); // First register a passkey so the authenticator has a credential await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); - await page.click('#webauthn-register-btn'); - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 }); + const countBefore = await getCredentialCount(page); + await registerPasskey(page); + await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, { + timeout: 5000, + }); // Logout - await request.post('/logout'); + await page.context().clearCookies(); // Go to login page await page.goto('/login');