Skip to content

Commit 4748e85

Browse files
committed
test: add full MFA flow E2E using a virtual authenticator
Chromium's CDP WebAuthn virtual authenticator drives the entire flow without hardware: enroll a passkey, password login, get redirected to the challenge page, verify with the passkey, confirm both factors are satisfied and the profile page renders the MFA badges. Runs in a new chromium-mfa Playwright project (tagged @mfa-enabled) against a server started with the mfa profile; the default projects exclude the tag since their specs assume MFA is off. The webServer profiles are now overridable via SPRING_PROFILES.
1 parent 2aa2910 commit 4748e85

2 files changed

Lines changed: 97 additions & 2 deletions

File tree

playwright/playwright.config.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,38 +84,55 @@ export default defineConfig({
8484
timeout: 10000,
8585
},
8686

87-
/* Configure projects for major browsers */
87+
/* Configure projects for major browsers.
88+
*
89+
* Tests tagged @mfa-enabled need the server running with the mfa profile and are excluded from
90+
* the default projects (whose specs assume MFA is off). Run them with:
91+
* SPRING_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa
92+
*/
8893
projects: [
8994
{
9095
name: 'chromium',
96+
grepInvert: /@mfa-enabled/,
9197
use: { ...devices['Desktop Chrome'] },
9298
},
9399

94100
{
95101
name: 'firefox',
102+
grepInvert: /@mfa-enabled/,
96103
use: { ...devices['Desktop Firefox'] },
97104
},
98105

99106
{
100107
name: 'webkit',
108+
grepInvert: /@mfa-enabled/,
101109
use: { ...devices['Desktop Safari'] },
102110
},
103111

104112
/* Test against mobile viewports */
105113
{
106114
name: 'Mobile Chrome',
115+
grepInvert: /@mfa-enabled/,
107116
use: { ...devices['Pixel 5'] },
108117
},
109118

110119
{
111120
name: 'Mobile Safari',
121+
grepInvert: /@mfa-enabled/,
112122
use: { ...devices['iPhone 12'] },
113123
},
124+
125+
/* MFA flow tests: Chromium only (CDP virtual authenticator), MFA-enabled server required */
126+
{
127+
name: 'chromium-mfa',
128+
grep: /@mfa-enabled/,
129+
use: { ...devices['Desktop Chrome'] },
130+
},
114131
],
115132

116133
/* Run your local dev server before starting the tests */
117134
webServer: {
118-
command: 'cd .. && ./gradlew bootRun --args="--spring.profiles.active=local,playwright-test"',
135+
command: `cd .. && ./gradlew bootRun --args="--spring.profiles.active=${process.env.SPRING_PROFILES || 'local,playwright-test'}"`,
119136
url: 'http://localhost:8080',
120137
reuseExistingServer: !process.env.CI,
121138
timeout: 120000,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { test, expect, generateTestUser, createAndLoginUser } from '../../src/fixtures';
2+
3+
/**
4+
* Full MFA flow E2E test using Chromium's CDP WebAuthn virtual authenticator.
5+
*
6+
* Requires the app to run with MFA enabled:
7+
* SPRING_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa
8+
* (or start the server yourself with those profiles; the mfa profile must come last so its
9+
* overrides win).
10+
*
11+
* Tagged @mfa-enabled so the default projects skip it — their specs assume MFA is off.
12+
*/
13+
test.describe('MFA Full Flow @mfa-enabled', () => {
14+
test('password login requires passkey verification before reaching protected pages', async ({
15+
page,
16+
testApiClient,
17+
cleanupEmails,
18+
}) => {
19+
const user = generateTestUser('mfa-e2e');
20+
cleanupEmails.push(user.email);
21+
22+
// Set up a virtual authenticator before any WebAuthn ceremony. automaticPresenceSimulation
23+
// auto-approves create()/get() prompts so no human touch is needed.
24+
const cdp = await page.context().newCDPSession(page);
25+
await cdp.send('WebAuthn.enable');
26+
await cdp.send('WebAuthn.addVirtualAuthenticator', {
27+
options: {
28+
protocol: 'ctap2',
29+
transport: 'internal',
30+
hasResidentKey: true,
31+
hasUserVerification: true,
32+
isUserVerified: true,
33+
automaticPresenceSimulation: true,
34+
},
35+
});
36+
37+
// Password login leaves the user partially authenticated: PASSWORD satisfied, WEBAUTHN missing.
38+
await createAndLoginUser(page, testApiClient, user);
39+
40+
const partialStatus = await (await page.request.get('/user/mfa/status')).json();
41+
expect(partialStatus.data.satisfiedFactors).toContain('PASSWORD');
42+
expect(partialStatus.data.missingFactors).toContain('WEBAUTHN');
43+
expect(partialStatus.data.fullyAuthenticated).toBe(false);
44+
45+
// Any protected page redirects to the challenge page (and must not redirect-loop).
46+
await page.goto('/user/update-user.html');
47+
await expect(page).toHaveURL(/\/user\/mfa\/webauthn-challenge\.html/);
48+
await expect(page.locator('#verifyPasskeyBtn')).toBeVisible();
49+
50+
// Enroll the user's first passkey. The mfa profile unprotects the enrollment endpoints so a
51+
// partially-authenticated user can register; the virtual authenticator answers the ceremony.
52+
await page.evaluate(async () => {
53+
const { registerPasskey } = await import('/js/user/webauthn-register.js');
54+
await registerPasskey('e2e-virtual-passkey');
55+
});
56+
57+
// Complete the challenge with the freshly enrolled passkey.
58+
await page.locator('#verifyPasskeyBtn').click();
59+
await page.waitForURL((url) => !url.pathname.includes('webauthn-challenge'), {
60+
timeout: 15000,
61+
});
62+
63+
// Both factors satisfied now.
64+
const fullStatus = await (await page.request.get('/user/mfa/status')).json();
65+
expect(fullStatus.data.fullyAuthenticated).toBe(true);
66+
expect(fullStatus.data.missingFactors).toEqual([]);
67+
68+
// Protected pages are reachable again.
69+
await page.goto('/user/update-user.html');
70+
await expect(page).toHaveURL(/\/user\/update-user\.html/);
71+
72+
// And the profile page renders the MFA badges from the status endpoint's data envelope.
73+
const badges = page.locator('#mfaStatusBadges');
74+
await expect(badges).toContainText('MFA Active');
75+
await expect(badges).toContainText('Fully Authenticated');
76+
await expect(badges).not.toContainText('Additional Factor Required');
77+
});
78+
});

0 commit comments

Comments
 (0)