create-nuxt-base 2.2.1 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,711 @@
1
+ import { expect, test } from '@nuxt/test-utils/playwright';
2
+ import type { BrowserContext, Page } from '@playwright/test';
3
+ import * as fs from 'node:fs';
4
+ import { MongoClient } from 'mongodb';
5
+ import {
6
+ extractTOTPSecret,
7
+ fillInput,
8
+ generateTestUser,
9
+ generateTOTP,
10
+ gotoAndWaitForHydration,
11
+ waitForHydration,
12
+ waitForURLAndHydration,
13
+ } from '@lenne.tech/nuxt-extensions/testing';
14
+
15
+ /**
16
+ * Comprehensive Better-Auth E2E Tests — Auth Lifecycle
17
+ *
18
+ * Tests the complete authentication lifecycle:
19
+ * 0. Full reset (database + browser)
20
+ * 1. Registration (with terms checkbox if signUpChecks enabled)
21
+ * 2. Email verification (if emailVerification enabled)
22
+ * 3. Passkey activation + login via Passkey
23
+ * 4. 2FA activation
24
+ * 5. Login via 2FA
25
+ * 6. Passkey deletion
26
+ * 7. 2FA deactivation
27
+ * 8. Login without 2FA
28
+ *
29
+ * Automatically detects the current backend configuration via GET /iam/features
30
+ * and adapts the test flow accordingly (skips non-applicable steps).
31
+ *
32
+ * ═══════════════════════════════════════════════════════════════════════════════
33
+ * CONFIGURATION SCENARIOS (for AI agents / CI)
34
+ * ═══════════════════════════════════════════════════════════════════════════════
35
+ *
36
+ * The tests should be run against ALL 4 backend configurations to ensure
37
+ * full coverage. The configuration is set in the betterAuth section of:
38
+ *
39
+ * Fullstack (lt fullstack init): projects/api/src/config.env.ts
40
+ * Standalone (nest-server-starter): src/config.env.ts
41
+ *
42
+ * After changing the config, restart the backend server and re-run the tests.
43
+ *
44
+ * Scenario 1 — Zero Config (default, everything enabled):
45
+ * No changes needed. Default nest-server-starter config.
46
+ * Expected features: cookies=true, emailVerification=true, signUpChecks=true,
47
+ * twoFactor=true, passkey=true
48
+ *
49
+ * Scenario 2 — Cookies, no verification/checks:
50
+ * betterAuth: {
51
+ * cookies: true,
52
+ * emailVerification: false,
53
+ * signUpChecks: false,
54
+ * }
55
+ * Expected features: jwt=false, emailVerification=false, signUpChecks=false,
56
+ * twoFactor=true, passkey=true
57
+ * Effect: No terms checkbox, no email verification step, direct login after register.
58
+ *
59
+ * Scenario 3 — JWT mode, everything else enabled:
60
+ * betterAuth: {
61
+ * cookies: false,
62
+ * }
63
+ * Expected features: jwt=true, emailVerification=true, signUpChecks=true,
64
+ * twoFactor=true, passkey=true
65
+ * Effect: Auth via JWT instead of cookies, all features active.
66
+ *
67
+ * Scenario 4 — JWT mode, no verification/checks:
68
+ * betterAuth: {
69
+ * cookies: false,
70
+ * emailVerification: false,
71
+ * signUpChecks: false,
72
+ * }
73
+ * Expected features: jwt=true, emailVerification=false, signUpChecks=false,
74
+ * twoFactor=true, passkey=true
75
+ * Effect: JWT mode, no terms checkbox, no email verification.
76
+ *
77
+ * ═══════════════════════════════════════════════════════════════════════════════
78
+ * BACKEND OPTIONS
79
+ * ═══════════════════════════════════════════════════════════════════════════════
80
+ *
81
+ * Either of these can serve as the API backend on port 3000:
82
+ *
83
+ * Option A — nest-server-starter (standalone template):
84
+ * Repository: nest-server-starter
85
+ * Config: src/config.env.ts → betterAuth section
86
+ * Start: cd <nest-server-starter> && npm run start > /tmp/nest-server.log 2>&1 &
87
+ *
88
+ * Option B — nest-server (direct, e.g. during nest-server development):
89
+ * Repository: nest-server
90
+ * Config: src/config.env.ts → betterAuth section
91
+ * Start: cd <nest-server> && npm run start > /tmp/nest-server.log 2>&1 &
92
+ *
93
+ * In a fullstack project (lt fullstack init), the API is at:
94
+ * Config: projects/api/src/config.env.ts → betterAuth section
95
+ * Start: cd projects/api && npm run start > /tmp/nest-server.log 2>&1 &
96
+ *
97
+ * The backend MUST be started with stdout redirected to a log file
98
+ * (default: /tmp/nest-server.log, override via NEST_SERVER_LOG env var)
99
+ * because email verification tokens are extracted from the server logs.
100
+ *
101
+ * ═══════════════════════════════════════════════════════════════════════════════
102
+ * HOW TO RUN ALL SCENARIOS (automated, for AI agents like Claude Code)
103
+ * ═══════════════════════════════════════════════════════════════════════════════
104
+ *
105
+ * For each scenario:
106
+ * 1. Edit config.env.ts → betterAuth section (see paths above)
107
+ * 2. Restart backend:
108
+ * pkill -f "nest-server" 2>/dev/null
109
+ * cd <backend-path> && npm run start > /tmp/nest-server.log 2>&1 &
110
+ * 3. Wait for backend ready: curl -s http://localhost:3000/ > /dev/null
111
+ * 4. Run tests:
112
+ * npx playwright test tests/e2e/auth-lifecycle.spec.ts
113
+ * 5. Check config banner in output to verify which scenario was detected
114
+ * 6. Restore config.env.ts to original state after all scenarios
115
+ *
116
+ * The test output includes a configuration banner showing the detected scenario:
117
+ * ╔═════════════════════════════╗
118
+ * ║ Szenario X: <description> ║
119
+ * ╚═════════════════════════════╝
120
+ *
121
+ * ═══════════════════════════════════════════════════════════════════════════════
122
+ *
123
+ * Requirements:
124
+ * - API: nest-server-starter OR nest-server running on port 3000
125
+ * (stdout redirected to /tmp/nest-server.log or NEST_SERVER_LOG)
126
+ * - Frontend: nuxt-base-starter running on port 3001
127
+ * - MongoDB: running on localhost:27017
128
+ *
129
+ * Run: npx playwright test tests/e2e/auth-lifecycle.spec.ts
130
+ */
131
+
132
+ // =============================================================================
133
+ // Types
134
+ // =============================================================================
135
+
136
+ interface Features {
137
+ emailVerification: boolean;
138
+ enabled: boolean;
139
+ jwt: boolean;
140
+ passkey: boolean;
141
+ resendCooldownSeconds: number;
142
+ signUpChecks: boolean;
143
+ socialProviders: string[];
144
+ twoFactor: boolean;
145
+ }
146
+
147
+ // =============================================================================
148
+ // Constants
149
+ // =============================================================================
150
+
151
+ const MONGO_URI = 'mongodb://127.0.0.1/nest-server-local';
152
+ const API_BASE = 'http://localhost:3000';
153
+ const FRONTEND_BASE = 'http://localhost:3001';
154
+
155
+ // Better-Auth collection names (default without prefix)
156
+ const COLLECTIONS = ['session', 'account', 'verification', 'passkey', 'twoFactor', 'backupCode'];
157
+
158
+ // =============================================================================
159
+ // MongoDB Helpers
160
+ // =============================================================================
161
+
162
+ async function resetTestData(email: string): Promise<void> {
163
+ const client = new MongoClient(MONGO_URI);
164
+ try {
165
+ await client.connect();
166
+ const db = client.db();
167
+
168
+ // Try to find user in the 'users' collection (Better-Auth modelName)
169
+ const user = await db.collection('users').findOne({ email });
170
+ if (user) {
171
+ const userId = user._id.toString();
172
+ for (const coll of COLLECTIONS) {
173
+ try {
174
+ await db.collection(coll).deleteMany({ userId });
175
+ } catch {
176
+ // Collection may not exist yet
177
+ }
178
+ }
179
+ try {
180
+ await db.collection('webauthn_challenge_mappings').deleteMany({ userId });
181
+ } catch {
182
+ // Collection may not exist
183
+ }
184
+ // Also clean verification by identifier (email)
185
+ try {
186
+ await db.collection('verification').deleteMany({ identifier: email });
187
+ } catch {
188
+ // Collection may not exist
189
+ }
190
+ await db.collection('users').deleteOne({ _id: user._id });
191
+ }
192
+ } finally {
193
+ await client.close();
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Extract email verification token from backend server logs.
199
+ *
200
+ * The nest-server logs verification URLs in this format:
201
+ * [EMAIL VERIFICATION] User: <email>, URL: <baseUrl>/auth/verify-email?token=<jwt>
202
+ *
203
+ * The token is a JWT (not stored in MongoDB), so we must read it from the logs.
204
+ * Set NEST_SERVER_LOG env var to point to the server log file.
205
+ * Default: /tmp/nest-server.log
206
+ */
207
+ async function getVerificationToken(email: string, maxRetries = 10): Promise<string | null> {
208
+ const logPath = process.env.NEST_SERVER_LOG || '/tmp/nest-server.log';
209
+
210
+ for (let i = 0; i < maxRetries; i++) {
211
+ try {
212
+ const log = fs.readFileSync(logPath, 'utf-8');
213
+ // Find the verification line for this email
214
+ const regex = new RegExp(`\\[EMAIL VERIFICATION\\] User: ${email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}, URL: .+?token=([^&\\s]+)`);
215
+ const match = log.match(regex);
216
+ if (match?.[1]) {
217
+ return match[1];
218
+ }
219
+ } catch {
220
+ // Log file may not exist yet
221
+ }
222
+ await new Promise((resolve) => setTimeout(resolve, 500));
223
+ }
224
+ return null;
225
+ }
226
+
227
+ // =============================================================================
228
+ // UI Helpers
229
+ // =============================================================================
230
+
231
+ async function loginWithEmail(page: Page, email: string, password: string): Promise<void> {
232
+ await gotoAndWaitForHydration(page, '/auth/login');
233
+ await page.locator('input[name="email"]').waitFor({ state: 'visible', timeout: 10000 });
234
+ await fillInput(page, 'input[name="email"]', email);
235
+ await fillInput(page, 'input[name="password"]', password);
236
+ await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
237
+ }
238
+
239
+ async function loginWith2FA(page: Page, email: string, password: string, totpSecret: string): Promise<void> {
240
+ await loginWithEmail(page, email, password);
241
+ await waitForURLAndHydration(page, /\/auth\/2fa/, { timeout: 10000 });
242
+
243
+ const totpCode = generateTOTP(totpSecret);
244
+ await page.locator('input').fill(totpCode);
245
+ await page.getByRole('button', { name: /verifizieren|bestätigen/i }).click();
246
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
247
+ }
248
+
249
+ async function logout(page: Page): Promise<void> {
250
+ const logoutButton = page.getByLabel('Logout');
251
+ await logoutButton.waitFor({ state: 'visible', timeout: 5000 });
252
+ await logoutButton.click();
253
+ await page.waitForURL(/\/auth\/login/, { timeout: 5000 });
254
+ await waitForHydration(page);
255
+ }
256
+
257
+ async function setupVirtualAuthenticator(context: BrowserContext, page: Page) {
258
+ const cdpSession = await context.newCDPSession(page);
259
+ await cdpSession.send('WebAuthn.enable');
260
+ const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
261
+ options: {
262
+ protocol: 'ctap2',
263
+ transport: 'internal',
264
+ hasResidentKey: true,
265
+ hasUserVerification: true,
266
+ isUserVerified: true,
267
+ },
268
+ });
269
+ return { cdpSession, authenticatorId };
270
+ }
271
+
272
+ async function cleanupAuthenticator(cdpSession: any, authenticatorId: string): Promise<void> {
273
+ try {
274
+ await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
275
+ await cdpSession.send('WebAuthn.disable');
276
+ } catch {
277
+ // Ignore cleanup errors
278
+ }
279
+ }
280
+
281
+ function getConfigScenarioName(features: Features): string {
282
+ const cookies = !features.jwt;
283
+ if (cookies && features.emailVerification && features.signUpChecks) return 'Szenario 1: Zero Config (alles aktiviert, Cookies)';
284
+ if (cookies && !features.emailVerification && !features.signUpChecks) return 'Szenario 2: Cookies, ohne EmailVerification/SignUpChecks';
285
+ if (!cookies && features.emailVerification && features.signUpChecks) return 'Szenario 3: JWT, alles aktiviert';
286
+ if (!cookies && !features.emailVerification && !features.signUpChecks) return 'Szenario 4: JWT, ohne EmailVerification/SignUpChecks';
287
+ return `Custom Config (jwt=${features.jwt}, emailVerification=${features.emailVerification}, signUpChecks=${features.signUpChecks})`;
288
+ }
289
+
290
+ // =============================================================================
291
+ // Tests
292
+ // =============================================================================
293
+
294
+ let apiAvailable = false;
295
+ let features: Features | null = null;
296
+
297
+ test.beforeAll(async ({ request }) => {
298
+ // Check API and Frontend availability
299
+ let frontendAvailable = false;
300
+
301
+ try {
302
+ const apiResponse = await request.get(`${API_BASE}/`);
303
+ apiAvailable = apiResponse.ok();
304
+ } catch {
305
+ apiAvailable = false;
306
+ }
307
+
308
+ try {
309
+ const frontendResponse = await request.get(`${FRONTEND_BASE}/`);
310
+ frontendAvailable = frontendResponse.ok();
311
+ } catch {
312
+ frontendAvailable = false;
313
+ }
314
+
315
+ if (!apiAvailable || !frontendAvailable) {
316
+ console.error('');
317
+ console.error('╔══════════════════════════════════════════════════════════════════╗');
318
+ console.error('║ COMPREHENSIVE E2E TESTS REQUIRE RUNNING SERVERS ║');
319
+ console.error('╠══════════════════════════════════════════════════════════════════╣');
320
+ console.error(`║ ${apiAvailable ? '✓' : '✗'} API Server (localhost:3000) - ${apiAvailable ? 'Running' : 'NOT RUNNING'} ║`);
321
+ console.error(`║ ${frontendAvailable ? '✓' : '✗'} Frontend (localhost:3001) - ${frontendAvailable ? 'Running' : 'NOT RUNNING'} ║`);
322
+ console.error('╠══════════════════════════════════════════════════════════════════╣');
323
+ console.error('║ Start servers: ║');
324
+ console.error('║ API: cd nest-server-starter && npm run start:local ║');
325
+ console.error('║ APP: cd nuxt-base-template && npm run dev ║');
326
+ console.error('╚══════════════════════════════════════════════════════════════════╝');
327
+ apiAvailable = false;
328
+ return;
329
+ }
330
+
331
+ apiAvailable = true;
332
+
333
+ // Fetch features to detect configuration
334
+ try {
335
+ const featuresResponse = await request.get(`${API_BASE}/iam/features`);
336
+ features = (await featuresResponse.json()) as Features;
337
+ } catch {
338
+ console.error('Could not fetch /iam/features - assuming zero config (defaults)');
339
+ features = {
340
+ emailVerification: true,
341
+ enabled: true,
342
+ jwt: false,
343
+ passkey: true,
344
+ resendCooldownSeconds: 60,
345
+ signUpChecks: true,
346
+ socialProviders: [],
347
+ twoFactor: true,
348
+ };
349
+ }
350
+
351
+ // Print configuration banner
352
+ console.info('');
353
+ console.info('╔══════════════════════════════════════════════════════════════════╗');
354
+ console.info('║ BETTER-AUTH E2E TEST CONFIGURATION ║');
355
+ console.info('╠══════════════════════════════════════════════════════════════════╣');
356
+ console.info(`║ ${getConfigScenarioName(features).padEnd(62)}║`);
357
+ console.info('╠══════════════════════════════════════════════════════════════════╣');
358
+ console.info(`║ JWT Mode: ${String(features.jwt).padEnd(40)}║`);
359
+ console.info(`║ Email Verification: ${String(features.emailVerification).padEnd(40)}║`);
360
+ console.info(`║ Sign-Up Checks: ${String(features.signUpChecks).padEnd(40)}║`);
361
+ console.info(`║ Two-Factor: ${String(features.twoFactor).padEnd(40)}║`);
362
+ console.info(`║ Passkey: ${String(features.passkey).padEnd(40)}║`);
363
+ console.info('╚══════════════════════════════════════════════════════════════════╝');
364
+ console.info('');
365
+ });
366
+
367
+ // =============================================================================
368
+ // Comprehensive Better-Auth Flow
369
+ // =============================================================================
370
+
371
+ test.describe.serial('Comprehensive Better-Auth E2E Flow', () => {
372
+ const testUser = generateTestUser('comprehensive');
373
+ let totpSecret: string | null = null;
374
+
375
+ // =========================================================================
376
+ // Step 0: Full Reset
377
+ // =========================================================================
378
+
379
+ test('Step 0: Full Reset (Database + Browser)', async ({ page }) => {
380
+ test.skip(!apiAvailable, 'Servers not running');
381
+
382
+ // Reset database for this test user
383
+ await resetTestData(testUser.email);
384
+
385
+ // Clear browser state
386
+ await page.context().clearCookies();
387
+
388
+ console.info(` Test user: ${testUser.email}`);
389
+ console.info(` Database reset complete`);
390
+ });
391
+
392
+ // =========================================================================
393
+ // Step 1: Registration
394
+ // =========================================================================
395
+
396
+ test('Step 1: Register new user', async ({ page }) => {
397
+ test.skip(!apiAvailable, 'Servers not running');
398
+
399
+ await gotoAndWaitForHydration(page, '/auth/register');
400
+ await page.locator('input[name="name"]').waitFor({ state: 'visible', timeout: 10000 });
401
+
402
+ // Fill registration form
403
+ await fillInput(page, 'input[name="name"]', testUser.name);
404
+ await fillInput(page, 'input[name="email"]', testUser.email);
405
+ await fillInput(page, 'input[name="password"]', testUser.password);
406
+ await fillInput(page, 'input[name="confirmPassword"]', testUser.password);
407
+
408
+ // Accept terms if signUpChecks is enabled
409
+ if (features?.signUpChecks) {
410
+ // NuxtUI UCheckbox renders both a button[role=checkbox] and a hidden input
411
+ // Use the aria-label to target the visible checkbox button specifically
412
+ const termsCheckbox = page.getByRole('checkbox', { name: /akzeptiere die AGB/i });
413
+ await termsCheckbox.waitFor({ state: 'visible', timeout: 5000 });
414
+ await termsCheckbox.check();
415
+ console.info(' Terms checkbox checked (signUpChecks enabled)');
416
+ }
417
+
418
+ // Submit form
419
+ await page.getByRole('button', { name: 'Konto erstellen' }).click();
420
+
421
+ if (features?.emailVerification) {
422
+ // Should redirect to verify-email page
423
+ await waitForURLAndHydration(page, /\/auth\/verify-email/, { timeout: 15000 });
424
+ await expect(page.getByText('E-Mail bestätigen')).toBeVisible({ timeout: 5000 });
425
+ console.info(' Redirected to email verification (emailVerification enabled)');
426
+ } else {
427
+ // Should show passkey prompt, skip it
428
+ const laterButton = page.getByRole('button', { name: 'Später einrichten' });
429
+ await laterButton.waitFor({ state: 'visible', timeout: 10000 });
430
+ await laterButton.click();
431
+
432
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
433
+ console.info(' Registered and logged in (no email verification)');
434
+ }
435
+
436
+ console.info(` Registered: ${testUser.email}`);
437
+ });
438
+
439
+ // =========================================================================
440
+ // Step 2: Email Verification (conditional)
441
+ // =========================================================================
442
+
443
+ test('Step 2: Verify email address', async ({ page }) => {
444
+ test.skip(!apiAvailable, 'Servers not running');
445
+ test.skip(!features?.emailVerification, 'Email verification disabled in current config');
446
+
447
+ // Fetch verification token from backend server logs
448
+ // The nest-server logs: [EMAIL VERIFICATION] User: <email>, URL: ...?token=<jwt>
449
+ const token = await getVerificationToken(testUser.email);
450
+ expect(token, 'Verification token not found in server logs. Ensure backend logs to /tmp/nest-server.log or set NEST_SERVER_LOG').not.toBeNull();
451
+
452
+ // Navigate to verify-email with token
453
+ await gotoAndWaitForHydration(page, `/auth/verify-email?token=${token}`);
454
+
455
+ // Wait for verification success (use heading to avoid ambiguity with toast notification)
456
+ await expect(page.getByRole('heading', { name: 'E-Mail bestätigt' })).toBeVisible({ timeout: 15000 });
457
+ console.info(' Email verified successfully');
458
+
459
+ // Click "Jetzt anmelden" to go to login
460
+ await page.getByRole('link', { name: 'Jetzt anmelden' }).click();
461
+ await waitForURLAndHydration(page, /\/auth\/login/, { timeout: 10000 });
462
+ console.info(' Redirected to login page');
463
+ });
464
+
465
+ // =========================================================================
466
+ // Step 3: Passkey Activation + Login via Passkey
467
+ // =========================================================================
468
+
469
+ test('Step 3: Activate Passkey and login via Passkey', async ({ page, context }) => {
470
+ test.skip(!apiAvailable, 'Servers not running');
471
+
472
+ // Login with email/password first
473
+ await loginWithEmail(page, testUser.email, testUser.password);
474
+
475
+ // Handle passkey prompt after login (if coming from registration without email verification)
476
+ // or direct to /app
477
+ try {
478
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
479
+ } catch {
480
+ // May still be on passkey prompt
481
+ const laterButton = page.getByRole('button', { name: 'Später einrichten' });
482
+ if (await laterButton.isVisible()) {
483
+ await laterButton.click();
484
+ await waitForURLAndHydration(page, /\/app/, { timeout: 10000 });
485
+ }
486
+ }
487
+
488
+ // Setup virtual authenticator
489
+ const { cdpSession, authenticatorId } = await setupVirtualAuthenticator(context, page);
490
+
491
+ try {
492
+ // Navigate to security settings
493
+ await gotoAndWaitForHydration(page, '/app/settings/security');
494
+
495
+ // Add passkey
496
+ await page.getByRole('button', { name: 'Passkey hinzufügen' }).click();
497
+ await page.getByPlaceholder('Name für den Passkey').fill('E2E-Fingerprint');
498
+ await page.getByRole('button', { name: 'Hinzufügen' }).click();
499
+
500
+ // Verify passkey appears in list
501
+ await expect(page.getByText('E2E-Fingerprint')).toBeVisible({ timeout: 15000 });
502
+ console.info(' Passkey "E2E-Fingerprint" registered');
503
+
504
+ // Logout
505
+ await logout(page);
506
+ console.info(' Logged out');
507
+
508
+ // Login with passkey
509
+ await gotoAndWaitForHydration(page, '/auth/login');
510
+ await page.getByRole('button', { name: 'Mit Passkey anmelden' }).click();
511
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
512
+ console.info(' Logged in via Passkey');
513
+
514
+ // Logout for next test
515
+ await logout(page);
516
+ } finally {
517
+ await cleanupAuthenticator(cdpSession, authenticatorId);
518
+ }
519
+ });
520
+
521
+ // =========================================================================
522
+ // Step 4: 2FA Activation
523
+ // =========================================================================
524
+
525
+ test('Step 4: Activate 2FA', async ({ page }) => {
526
+ test.skip(!apiAvailable, 'Servers not running');
527
+
528
+ // Login with email/password
529
+ await loginWithEmail(page, testUser.email, testUser.password);
530
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
531
+
532
+ // Navigate to security settings
533
+ await gotoAndWaitForHydration(page, '/app/settings/security');
534
+
535
+ // Wait for 2FA section
536
+ const enableButton = page.getByRole('button', { name: '2FA aktivieren' });
537
+ await enableButton.waitFor({ state: 'visible', timeout: 10000 });
538
+
539
+ // Fill password
540
+ const passwordInput = page.locator('input[type="password"]');
541
+ await passwordInput.click();
542
+ await page.keyboard.type(testUser.password, { delay: 5 });
543
+
544
+ // Intercept the 2FA enable response to extract TOTP URI
545
+ const responsePromise = page.waitForResponse((resp) => resp.url().includes('/two-factor/enable') && resp.status() === 200);
546
+
547
+ // Click enable button
548
+ await enableButton.click();
549
+
550
+ // Extract TOTP secret from API response
551
+ const response = await responsePromise;
552
+ const responseBody = await response.json();
553
+ const totpUri = responseBody.totpURI || responseBody.data?.totpURI;
554
+ expect(totpUri, '2FA enable response should contain totpURI').toBeTruthy();
555
+
556
+ const secret = extractTOTPSecret(totpUri);
557
+ expect(secret, 'TOTP secret should be extractable from URI').not.toBeNull();
558
+ totpSecret = secret;
559
+
560
+ // Wait for QR code SVG to render
561
+ await page.locator('.bg-white svg').waitFor({ state: 'visible', timeout: 10000 });
562
+
563
+ // Generate and enter TOTP code
564
+ const totpCode = generateTOTP(secret!);
565
+ await fillInput(page, 'input[placeholder="000000"]', totpCode);
566
+ await page.getByRole('button', { name: 'Verifizieren' }).click();
567
+
568
+ // Wait for backup codes modal and dismiss
569
+ await expect(page.getByRole('heading', { name: 'Backup-Codes' })).toBeVisible({ timeout: 10000 });
570
+ await page.keyboard.press('Escape');
571
+
572
+ // Verify 2FA is now active
573
+ await expect(page.getByText('2FA ist aktiviert')).toBeVisible({ timeout: 5000 });
574
+ console.info(' 2FA activated, TOTP secret stored');
575
+
576
+ // Logout
577
+ await logout(page);
578
+ });
579
+
580
+ // =========================================================================
581
+ // Step 5: Login with 2FA
582
+ // =========================================================================
583
+
584
+ test('Step 5: Login with 2FA', async ({ page }) => {
585
+ test.skip(!apiAvailable, 'Servers not running');
586
+ test.skip(!totpSecret, 'TOTP secret not available (2FA activation failed)');
587
+
588
+ // Login with email/password → should redirect to 2FA
589
+ await loginWithEmail(page, testUser.email, testUser.password);
590
+ await waitForURLAndHydration(page, /\/auth\/2fa/, { timeout: 10000 });
591
+
592
+ // Enter TOTP code
593
+ const totpCode = generateTOTP(totpSecret!);
594
+ await page.locator('input').fill(totpCode);
595
+ await page.getByRole('button', { name: /verifizieren|bestätigen/i }).click();
596
+
597
+ // Should redirect to app
598
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
599
+ await expect(page.getByText(testUser.email).first()).toBeVisible({ timeout: 5000 });
600
+ console.info(' Logged in with 2FA');
601
+
602
+ // Logout
603
+ await logout(page);
604
+ });
605
+
606
+ // =========================================================================
607
+ // Step 6: Delete Passkey
608
+ // =========================================================================
609
+
610
+ test('Step 6: Delete Passkey', async ({ page }) => {
611
+ test.skip(!apiAvailable, 'Servers not running');
612
+ test.skip(!totpSecret, 'TOTP secret not available (2FA required for login)');
613
+
614
+ // Login with 2FA
615
+ await loginWith2FA(page, testUser.email, testUser.password, totpSecret!);
616
+
617
+ // Navigate to security settings
618
+ await gotoAndWaitForHydration(page, '/app/settings/security');
619
+
620
+ // Find and delete the passkey
621
+ await expect(page.getByText('E2E-Fingerprint')).toBeVisible({ timeout: 10000 });
622
+
623
+ // Find the passkey row container that has both the name and delete button
624
+ // Structure: div.py-3 > [name div] + UButton(Löschen)
625
+ const passkeyRow = page.locator('div.py-3').filter({ hasText: 'E2E-Fingerprint' });
626
+ await passkeyRow.getByRole('button', { name: 'Löschen' }).click();
627
+
628
+ // Verify passkey is removed
629
+ await expect(page.getByText('E2E-Fingerprint')).not.toBeVisible({ timeout: 10000 });
630
+ console.info(' Passkey "E2E-Fingerprint" deleted');
631
+
632
+ // Logout
633
+ await logout(page);
634
+ });
635
+
636
+ // =========================================================================
637
+ // Step 7: Deactivate 2FA
638
+ // =========================================================================
639
+
640
+ test('Step 7: Deactivate 2FA', async ({ page }) => {
641
+ test.skip(!apiAvailable, 'Servers not running');
642
+ test.skip(!totpSecret, 'TOTP secret not available (2FA required for login)');
643
+
644
+ // Login with 2FA
645
+ await loginWith2FA(page, testUser.email, testUser.password, totpSecret!);
646
+
647
+ // Navigate to security settings
648
+ await gotoAndWaitForHydration(page, '/app/settings/security');
649
+
650
+ // Verify 2FA is currently active
651
+ await expect(page.getByText('2FA ist aktiviert')).toBeVisible({ timeout: 5000 });
652
+
653
+ // Click deactivate button
654
+ await page.getByRole('button', { name: '2FA deaktivieren' }).first().click();
655
+
656
+ // Fill password in the deactivation form
657
+ const passwordInput = page.locator('input[type="password"]');
658
+ await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
659
+ await passwordInput.click();
660
+ await page.keyboard.type(testUser.password, { delay: 5 });
661
+
662
+ // Click the red deactivate confirmation button
663
+ const confirmButton = page.getByRole('button', { name: '2FA deaktivieren' }).last();
664
+ await confirmButton.click();
665
+
666
+ // Verify 2FA is now deactivated
667
+ await expect(page.getByText('2FA ist deaktiviert')).toBeVisible({ timeout: 10000 });
668
+ console.info(' 2FA deactivated');
669
+
670
+ // Clear TOTP secret since 2FA is disabled
671
+ totpSecret = null;
672
+
673
+ // Logout
674
+ await logout(page);
675
+ });
676
+
677
+ // =========================================================================
678
+ // Step 8: Login without 2FA
679
+ // =========================================================================
680
+
681
+ test('Step 8: Login without 2FA', async ({ page }) => {
682
+ test.skip(!apiAvailable, 'Servers not running');
683
+
684
+ // Login with email/password
685
+ await loginWithEmail(page, testUser.email, testUser.password);
686
+
687
+ // Should redirect DIRECTLY to /app (no 2FA redirect)
688
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
689
+
690
+ // Verify we are NOT on the 2FA page
691
+ expect(page.url()).not.toContain('/auth/2fa');
692
+
693
+ // Verify user is logged in
694
+ await expect(page.getByText(testUser.email).first()).toBeVisible({ timeout: 5000 });
695
+ console.info(' Logged in without 2FA - direct to /app');
696
+ });
697
+
698
+ // =========================================================================
699
+ // Cleanup
700
+ // =========================================================================
701
+
702
+ test.afterAll(async () => {
703
+ // Clean up test data from database
704
+ try {
705
+ await resetTestData(testUser.email);
706
+ console.info(` Cleanup: test user ${testUser.email} removed from database`);
707
+ } catch (error) {
708
+ console.error(` Cleanup failed: ${error}`);
709
+ }
710
+ });
711
+ });