fix(e2e): fix WebAuthn and integration test failures
- Use localhost instead of 127.0.0.1 as TARGET_URL so the WebAuthn RP ID
is a valid domain (the spec forbids IP addresses)
- Replace request.post('/logout') with page.context().clearCookies() since
Playwright's request fixture has a separate cookie jar from the page
- Add registerPasskey() helper that waits for 'load' event to reliably
detect the page reload after successful registration
- Track credential count with getCredentialCount() since credentials
accumulate across serial tests sharing the same database
- Fix login.spec.js selector from #webauthn-login-form to #webauthn-login-btn
to match the actual template
All 57 E2E tests now pass (50 migrated + 7 WebAuthn).
This commit is contained in:
parent
71ddf5d8ff
commit
70c97233c5
4 changed files with 70 additions and 43 deletions
|
|
@ -4,7 +4,7 @@ const { test, expect } = require('@playwright/test');
|
||||||
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
||||||
|
|
||||||
test.describe('Full user journey', () => {
|
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
|
// Verify fixtures are loaded
|
||||||
expect(fixtures.register_token).toBeTruthy();
|
expect(fixtures.register_token).toBeTruthy();
|
||||||
|
|
||||||
|
|
@ -40,7 +40,9 @@ test.describe('Full user journey', () => {
|
||||||
await expect(successMsg).toContainText('Password updated');
|
await expect(successMsg).toContainText('Password updated');
|
||||||
|
|
||||||
// ---- Step 3: Logout ----
|
// ---- 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
|
// Navigate to credentials — should redirect to login since we're logged out
|
||||||
await page.goto('/manage/credentials');
|
await page.goto('/manage/credentials');
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,8 @@ test.describe('Login page', () => {
|
||||||
await expect(page.locator('form[hx-post="/login/password"] button[type="submit"]')).toBeVisible();
|
await expect(page.locator('form[hx-post="/login/password"] button[type="submit"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('WebAuthn login form exists', async ({ page }) => {
|
test('WebAuthn login button exists', async ({ page }) => {
|
||||||
await expect(page.locator('#webauthn-login-form')).toHaveCount(1);
|
await expect(page.locator('#webauthn-login-btn')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
PORT="${E2E_PORT:-8099}"
|
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 ---
|
# --- Temp directory for e2e state ---
|
||||||
E2E_TMPDIR="$(mktemp -d)"
|
E2E_TMPDIR="$(mktemp -d)"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,24 @@ async function loginWithPassword(page, username, password) {
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
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 <li> items in #webauthn-list.
|
||||||
|
*/
|
||||||
|
async function getCredentialCount(page) {
|
||||||
|
return page.locator('#webauthn-list li').count();
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('WebAuthn passkey flows', () => {
|
test.describe('WebAuthn passkey flows', () => {
|
||||||
test.describe('Registration', () => {
|
test.describe('Registration', () => {
|
||||||
test('register a passkey from credentials page', async ({ page }) => {
|
test('register a passkey from credentials page', async ({ page }) => {
|
||||||
|
|
@ -57,17 +75,15 @@ test.describe('WebAuthn passkey flows', () => {
|
||||||
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
||||||
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
||||||
|
|
||||||
// Before registration, page should show no security keys
|
const countBefore = await getCredentialCount(page);
|
||||||
await expect(page.locator('#webauthn-list')).toContainText('No security keys registered');
|
|
||||||
|
|
||||||
// Click the register button — the virtual authenticator handles the ceremony
|
// 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
|
// After reload one more passkey should appear in the list
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 10000 });
|
await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, {
|
||||||
|
timeout: 5000,
|
||||||
// After reload the passkey should appear in the list
|
});
|
||||||
await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 });
|
|
||||||
} finally {
|
} finally {
|
||||||
await removeVirtualAuthenticator(cdpSession, authenticatorId);
|
await removeVirtualAuthenticator(cdpSession, authenticatorId);
|
||||||
}
|
}
|
||||||
|
|
@ -79,16 +95,17 @@ test.describe('WebAuthn passkey flows', () => {
|
||||||
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
||||||
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
||||||
|
|
||||||
|
const countBefore = await getCredentialCount(page);
|
||||||
|
|
||||||
// Register a passkey
|
// Register a passkey
|
||||||
await page.click('#webauthn-register-btn');
|
await registerPasskey(page);
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Exactly one credential should be listed
|
// One more credential should be listed
|
||||||
const items = page.locator('#webauthn-list li');
|
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
|
// The last item should be visible (newly added)
|
||||||
await expect(items.first()).toBeVisible();
|
await expect(items.last()).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await removeVirtualAuthenticator(cdpSession, authenticatorId);
|
await removeVirtualAuthenticator(cdpSession, authenticatorId);
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +115,6 @@ test.describe('WebAuthn passkey flows', () => {
|
||||||
test.describe('Authentication', () => {
|
test.describe('Authentication', () => {
|
||||||
test('full round-trip: register passkey, logout, login with passkey', async ({
|
test('full round-trip: register passkey, logout, login with passkey', async ({
|
||||||
page,
|
page,
|
||||||
request,
|
|
||||||
}) => {
|
}) => {
|
||||||
let cdpSession, authenticatorId;
|
let cdpSession, authenticatorId;
|
||||||
try {
|
try {
|
||||||
|
|
@ -106,14 +122,16 @@ test.describe('WebAuthn passkey flows', () => {
|
||||||
|
|
||||||
// Step 1: Log in with password and register a passkey
|
// Step 1: Log in with password and register a passkey
|
||||||
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
||||||
await page.click('#webauthn-register-btn');
|
const countBefore = await getCredentialCount(page);
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 10000 });
|
await registerPasskey(page);
|
||||||
|
|
||||||
// Verify the passkey is in the list
|
// Verify a new passkey was added
|
||||||
await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 });
|
await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
// Step 2: Logout
|
// Step 2: Logout
|
||||||
await request.post('/logout');
|
await page.context().clearCookies();
|
||||||
|
|
||||||
// Step 3: Go to login page and authenticate with the passkey (usernameless)
|
// Step 3: Go to login page and authenticate with the passkey (usernameless)
|
||||||
await page.goto('/login');
|
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;
|
let cdpSession, authenticatorId;
|
||||||
try {
|
try {
|
||||||
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
||||||
|
|
||||||
// Register a passkey via password login first
|
// Register a passkey via password login first
|
||||||
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
||||||
await page.click('#webauthn-register-btn');
|
const countBefore = await getCredentialCount(page);
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 10000 });
|
await registerPasskey(page);
|
||||||
await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 });
|
await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
await request.post('/logout');
|
await page.context().clearCookies();
|
||||||
|
|
||||||
// Login with passkey
|
// Login with passkey
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|
@ -162,23 +182,24 @@ test.describe('WebAuthn passkey flows', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Deletion', () => {
|
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;
|
let cdpSession, authenticatorId;
|
||||||
try {
|
try {
|
||||||
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
||||||
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
||||||
|
|
||||||
|
const countBefore = await getCredentialCount(page);
|
||||||
|
|
||||||
// Register a passkey
|
// Register a passkey
|
||||||
await page.click('#webauthn-register-btn');
|
await registerPasskey(page);
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 10000 });
|
await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, {
|
||||||
await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 });
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
// The credentials template does not expose a delete button in the UI.
|
// The credentials template does not expose a delete button in the UI.
|
||||||
// Test the DELETE API endpoint directly instead.
|
// Verify registration was successful by checking the page content directly.
|
||||||
const credResponse = await request.get('/manage/credentials');
|
const html = await page.content();
|
||||||
const html = await credResponse.text();
|
|
||||||
|
|
||||||
// Extract the credential ID from the page HTML (data attribute or link)
|
|
||||||
// The template renders <li> items; the API endpoint is DELETE /manage/credentials/webauthn/{id}
|
// The template renders <li> items; the API endpoint is DELETE /manage/credentials/webauthn/{id}
|
||||||
// Since the template doesn't include IDs in the HTML, verify registration was successful
|
// 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.
|
// 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();
|
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;
|
let cdpSession, authenticatorId;
|
||||||
try {
|
try {
|
||||||
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
({ cdpSession, authenticatorId } = await addVirtualAuthenticator(page));
|
||||||
|
|
||||||
// First register a passkey so the authenticator has a credential
|
// First register a passkey so the authenticator has a credential
|
||||||
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password);
|
||||||
await page.click('#webauthn-register-btn');
|
const countBefore = await getCredentialCount(page);
|
||||||
await page.waitForURL('**/manage/credentials', { timeout: 10000 });
|
await registerPasskey(page);
|
||||||
await expect(page.locator('#webauthn-list li')).toHaveCount(1, { timeout: 5000 });
|
await expect(page.locator('#webauthn-list li')).toHaveCount(countBefore + 1, {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
await request.post('/logout');
|
await page.context().clearCookies();
|
||||||
|
|
||||||
// Go to login page
|
// Go to login page
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue