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');