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.
- package/CHANGELOG.md +23 -22
- package/nuxt-base-template/app/components/Modal/ModalBackupCodes.vue +2 -2
- package/nuxt-base-template/app/pages/auth/register.vue +3 -3
- package/nuxt-base-template/app/pages/auth/reset-password.vue +2 -1
- package/nuxt-base-template/app/pages/auth/verify-email.vue +18 -2
- package/nuxt-base-template/package-lock.json +170 -12
- package/nuxt-base-template/package.json +11 -10
- package/nuxt-base-template/tests/e2e/auth-feature-order.spec.ts +360 -0
- package/nuxt-base-template/tests/e2e/auth-lifecycle.spec.ts +711 -0
- package/nuxt-base-template/tests/unit/auth/auth.spec.ts +1 -7
- package/nuxt-base-template/tests/unit/auth/error-translation.spec.ts +1 -4
- package/package.json +1 -1
- package/nuxt-base-template/tests/e2e/auth.spec.ts +0 -467
|
@@ -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
|
+
});
|