create-nuxt-base 2.2.0 → 2.2.2

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,379 @@
1
+ import { expect, test } from '@nuxt/test-utils/playwright';
2
+ import type { Page } from '@playwright/test';
3
+ import * as fs from 'node:fs';
4
+ import {
5
+ extractTOTPSecret,
6
+ fillInput,
7
+ generateTestUser,
8
+ generateTOTP,
9
+ gotoAndWaitForHydration,
10
+ waitForURLAndHydration,
11
+ } from '@lenne.tech/nuxt-extensions/testing';
12
+
13
+ /**
14
+ * Authentication E2E Tests - Feature Ordering & Error Translations
15
+ *
16
+ * Tests:
17
+ * - Test 1: Register → 2FA → Passkey (without logout) - order independence
18
+ * - Test 2: Register → Passkey → 2FA (without logout) - order independence
19
+ * - Test 3: Error Translations (German error messages, i18n endpoint)
20
+ *
21
+ * Automatically detects backend configuration via /iam/features.
22
+ *
23
+ * Requirements:
24
+ * - API: nest-server-starter OR nest-server running on port 3000
25
+ * (stdout redirected to /tmp/nest-server.log or NEST_SERVER_LOG)
26
+ * - Frontend: nuxt-base-starter running on port 3001
27
+ *
28
+ * See auth-lifecycle.spec.ts for full documentation on backend options,
29
+ * configuration scenarios, and how to run against all 4 configurations.
30
+ *
31
+ * Run: npx playwright test tests/e2e/auth-feature-order.spec.ts
32
+ */
33
+
34
+ // =============================================================================
35
+ // Types
36
+ // =============================================================================
37
+
38
+ interface Features {
39
+ emailVerification: boolean;
40
+ enabled: boolean;
41
+ jwt: boolean;
42
+ passkey: boolean;
43
+ signUpChecks: boolean;
44
+ twoFactor: boolean;
45
+ }
46
+
47
+ // =============================================================================
48
+ // Constants
49
+ // =============================================================================
50
+
51
+ const API_BASE = 'http://localhost:3000';
52
+ const FRONTEND_BASE = 'http://localhost:3001';
53
+
54
+ // =============================================================================
55
+ // Helpers
56
+ // =============================================================================
57
+
58
+ /**
59
+ * Extract email verification token from backend server logs.
60
+ * The nest-server logs: [EMAIL VERIFICATION] User: <email>, URL: ...?token=<jwt>
61
+ */
62
+ function getVerificationTokenFromLog(email: string): string | null {
63
+ const logPath = process.env.NEST_SERVER_LOG || '/tmp/nest-server.log';
64
+ try {
65
+ const log = fs.readFileSync(logPath, 'utf-8');
66
+ const regex = new RegExp(`\\[EMAIL VERIFICATION\\] User: ${email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}, URL: .+?token=([^&\\s]+)`);
67
+ const match = log.match(regex);
68
+ return match?.[1] ?? null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Register a new user via UI.
76
+ * Adapts to current configuration (terms checkbox, email verification).
77
+ */
78
+ async function registerUser(
79
+ page: Page,
80
+ user: { email: string; password: string; name: string },
81
+ features: Features,
82
+ ): Promise<void> {
83
+ await gotoAndWaitForHydration(page, '/auth/register');
84
+ await page.locator('input[name="name"]').waitFor({ state: 'visible', timeout: 10000 });
85
+
86
+ await fillInput(page, 'input[name="name"]', user.name);
87
+ await fillInput(page, 'input[name="email"]', user.email);
88
+ await fillInput(page, 'input[name="password"]', user.password);
89
+ await fillInput(page, 'input[name="confirmPassword"]', user.password);
90
+
91
+ // Accept terms if signUpChecks is enabled
92
+ if (features.signUpChecks) {
93
+ const termsCheckbox = page.getByRole('checkbox', { name: /akzeptiere die AGB/i });
94
+ await termsCheckbox.waitFor({ state: 'visible', timeout: 5000 });
95
+ await termsCheckbox.check();
96
+ }
97
+
98
+ await page.getByRole('button', { name: 'Konto erstellen' }).click();
99
+
100
+ if (features.emailVerification) {
101
+ // Wait for redirect to verify-email page
102
+ await waitForURLAndHydration(page, /\/auth\/verify-email/, { timeout: 15000 });
103
+
104
+ // Extract token from backend logs and verify email
105
+ let token: string | null = null;
106
+ for (let i = 0; i < 10; i++) {
107
+ token = getVerificationTokenFromLog(user.email);
108
+ if (token) break;
109
+ await new Promise(resolve => setTimeout(resolve, 500));
110
+ }
111
+ expect(token, 'Verification token not found in server logs').not.toBeNull();
112
+
113
+ await gotoAndWaitForHydration(page, `/auth/verify-email?token=${token}`);
114
+ await expect(page.getByRole('heading', { name: 'E-Mail bestätigt' })).toBeVisible({ timeout: 15000 });
115
+
116
+ // Login after verification
117
+ await page.getByRole('link', { name: 'Jetzt anmelden' }).click();
118
+ await waitForURLAndHydration(page, /\/auth\/login/, { timeout: 10000 });
119
+ await loginWithEmail(page, user.email, user.password);
120
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
121
+ } else {
122
+ // Wait for passkey prompt and dismiss it
123
+ const laterButton = page.getByRole('button', { name: 'Später einrichten' });
124
+ await laterButton.waitFor({ state: 'visible', timeout: 10000 });
125
+ await laterButton.click();
126
+ await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Login with email and password via UI
132
+ */
133
+ async function loginWithEmail(page: Page, email: string, password: string): Promise<void> {
134
+ await gotoAndWaitForHydration(page, '/auth/login');
135
+ await page.locator('input[name="email"]').waitFor({ state: 'visible', timeout: 10000 });
136
+ await fillInput(page, 'input[name="email"]', email);
137
+ await fillInput(page, 'input[name="password"]', password);
138
+ await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
139
+ }
140
+
141
+ /**
142
+ * Enable 2FA and return the TOTP secret.
143
+ * Uses network interception to extract the TOTP URI (QR code is SVG via v-html).
144
+ */
145
+ async function enable2FA(page: Page, password: string): Promise<string> {
146
+ await gotoAndWaitForHydration(page, '/app/settings/security');
147
+
148
+ const enableButton = page.getByRole('button', { name: '2FA aktivieren' });
149
+ await enableButton.waitFor({ state: 'visible', timeout: 10000 });
150
+
151
+ const passwordInput = page.locator('input[type="password"]');
152
+ await passwordInput.click();
153
+ await page.keyboard.type(password, { delay: 5 });
154
+
155
+ // Intercept the 2FA enable response to extract TOTP URI
156
+ const responsePromise = page.waitForResponse(
157
+ resp => resp.url().includes('/two-factor/enable') && resp.status() === 200,
158
+ );
159
+
160
+ await enableButton.click();
161
+
162
+ const response = await responsePromise;
163
+ const responseBody = await response.json();
164
+ const totpUri = responseBody.totpURI || responseBody.data?.totpURI;
165
+ expect(totpUri, '2FA enable response should contain totpURI').toBeTruthy();
166
+
167
+ const secret = extractTOTPSecret(totpUri);
168
+ expect(secret).not.toBeNull();
169
+
170
+ // Wait for QR code SVG to render
171
+ await page.locator('.bg-white svg').waitFor({ state: 'visible', timeout: 10000 });
172
+
173
+ // Verify TOTP
174
+ const totpCode = generateTOTP(secret!);
175
+ await fillInput(page, 'input[placeholder="000000"]', totpCode);
176
+ await page.getByRole('button', { name: 'Verifizieren' }).click();
177
+
178
+ // Close backup codes dialog
179
+ await expect(page.getByRole('heading', { name: 'Backup-Codes' })).toBeVisible({ timeout: 10000 });
180
+ await page.keyboard.press('Escape');
181
+
182
+ return secret!;
183
+ }
184
+
185
+ /**
186
+ * Cleanup Virtual Authenticator
187
+ */
188
+ async function cleanupAuthenticator(cdpSession: any, authenticatorId: string): Promise<void> {
189
+ try {
190
+ await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
191
+ await cdpSession.send('WebAuthn.disable');
192
+ } catch {
193
+ // Ignore cleanup errors
194
+ }
195
+ }
196
+
197
+ // =============================================================================
198
+ // API Availability & Feature Detection
199
+ // =============================================================================
200
+
201
+ let apiAvailable = false;
202
+ let features: Features | null = null;
203
+
204
+ test.beforeAll(async ({ request }) => {
205
+ let frontendAvailable = false;
206
+
207
+ try {
208
+ const apiResponse = await request.get(`${API_BASE}/`);
209
+ apiAvailable = apiResponse.ok();
210
+ } catch {
211
+ apiAvailable = false;
212
+ }
213
+
214
+ try {
215
+ const frontendResponse = await request.get(`${FRONTEND_BASE}/`);
216
+ frontendAvailable = frontendResponse.ok();
217
+ } catch {
218
+ frontendAvailable = false;
219
+ }
220
+
221
+ if (!apiAvailable || !frontendAvailable) {
222
+ console.error('');
223
+ console.error('╔══════════════════════════════════════════════════════════════╗');
224
+ console.error('║ E2E TESTS REQUIRE RUNNING SERVERS ║');
225
+ console.error('╠══════════════════════════════════════════════════════════════╣');
226
+ console.error(`║ ${apiAvailable ? '✓' : '✗'} API Server (localhost:3000) ║`);
227
+ console.error(`║ ${frontendAvailable ? '✓' : '✗'} Frontend (localhost:3001) ║`);
228
+ console.error('╚══════════════════════════════════════════════════════════════╝');
229
+ apiAvailable = false;
230
+ return;
231
+ }
232
+
233
+ apiAvailable = true;
234
+
235
+ // Detect backend configuration
236
+ try {
237
+ const featuresResponse = await request.get(`${API_BASE}/iam/features`);
238
+ features = await featuresResponse.json() as Features;
239
+ } catch {
240
+ features = {
241
+ emailVerification: true,
242
+ enabled: true,
243
+ jwt: false,
244
+ passkey: true,
245
+ signUpChecks: true,
246
+ twoFactor: true,
247
+ };
248
+ }
249
+ });
250
+
251
+ // =============================================================================
252
+ // Test 1: Register -> 2FA -> Passkey (without logout)
253
+ // =============================================================================
254
+
255
+ test.describe.serial('Test 1: Register -> 2FA -> Passkey (no logout)', () => {
256
+ const testUser = generateTestUser('2fa-then-passkey');
257
+
258
+ test('Register, enable 2FA, then add Passkey without logout', async ({ page, context }) => {
259
+ test.skip(!apiAvailable, 'Servers not running');
260
+
261
+ // Register (adapts to config)
262
+ await registerUser(page, testUser, features!);
263
+
264
+ // Enable 2FA
265
+ await enable2FA(page, testUser.password);
266
+
267
+ // Add Passkey
268
+ const cdpSession = await context.newCDPSession(page);
269
+ await cdpSession.send('WebAuthn.enable');
270
+
271
+ const { authenticatorId } = await cdpSession.send(
272
+ 'WebAuthn.addVirtualAuthenticator',
273
+ {
274
+ options: {
275
+ protocol: 'ctap2',
276
+ transport: 'internal',
277
+ hasResidentKey: true,
278
+ hasUserVerification: true,
279
+ isUserVerified: true,
280
+ },
281
+ },
282
+ );
283
+
284
+ try {
285
+ await gotoAndWaitForHydration(page, '/app/settings/security');
286
+ await page.getByRole('button', { name: 'Passkey hinzufügen' }).click();
287
+ await page.getByPlaceholder('Name für den Passkey').fill('After-2FA-Passkey');
288
+ await page.getByRole('button', { name: 'Hinzufügen' }).click();
289
+
290
+ await expect(page.getByText('After-2FA-Passkey')).toBeVisible({ timeout: 15000 });
291
+ await expect(page.getByText('2FA ist aktiviert')).toBeVisible();
292
+ } finally {
293
+ await cleanupAuthenticator(cdpSession, authenticatorId);
294
+ }
295
+ });
296
+ });
297
+
298
+ // =============================================================================
299
+ // Test 2: Register -> Passkey -> 2FA (without logout)
300
+ // =============================================================================
301
+
302
+ test.describe.serial('Test 2: Register -> Passkey -> 2FA (no logout)', () => {
303
+ const testUser = generateTestUser('passkey-then-2fa');
304
+
305
+ test('Register, add Passkey, then enable 2FA without logout', async ({ page, context }) => {
306
+ test.skip(!apiAvailable, 'Servers not running');
307
+
308
+ // Register (adapts to config)
309
+ await registerUser(page, testUser, features!);
310
+
311
+ // Add Passkey first
312
+ const cdpSession = await context.newCDPSession(page);
313
+ await cdpSession.send('WebAuthn.enable');
314
+
315
+ const { authenticatorId } = await cdpSession.send(
316
+ 'WebAuthn.addVirtualAuthenticator',
317
+ {
318
+ options: {
319
+ protocol: 'ctap2',
320
+ transport: 'internal',
321
+ hasResidentKey: true,
322
+ hasUserVerification: true,
323
+ isUserVerified: true,
324
+ },
325
+ },
326
+ );
327
+
328
+ try {
329
+ await gotoAndWaitForHydration(page, '/app/settings/security');
330
+ await page.getByRole('button', { name: 'Passkey hinzufügen' }).click();
331
+ await page.getByPlaceholder('Name für den Passkey').fill('Before-2FA-Passkey');
332
+ await page.getByRole('button', { name: 'Hinzufügen' }).click();
333
+
334
+ await expect(page.getByText('Before-2FA-Passkey')).toBeVisible({ timeout: 15000 });
335
+
336
+ // Enable 2FA
337
+ await enable2FA(page, testUser.password);
338
+
339
+ // Verify both are active
340
+ await gotoAndWaitForHydration(page, '/app/settings/security');
341
+ await expect(page.getByText('2FA ist aktiviert')).toBeVisible();
342
+ await expect(page.getByText('Before-2FA-Passkey')).toBeVisible();
343
+ } finally {
344
+ await cleanupAuthenticator(cdpSession, authenticatorId);
345
+ }
346
+ });
347
+ });
348
+
349
+ // =============================================================================
350
+ // Test 3: Error Translations
351
+ // =============================================================================
352
+
353
+ test.describe('Test 3: Error Translations', () => {
354
+ test('3.1 Invalid credentials shows German error message', async ({ page }) => {
355
+ test.skip(!apiAvailable, 'Servers not running');
356
+
357
+ await loginWithEmail(page, 'invalid@test.com', 'WrongPassword123!');
358
+
359
+ const toast = page.locator('li[role="alert"]');
360
+ await expect(toast).toBeVisible({ timeout: 10000 });
361
+ await expect(toast).toContainText('Ungültige Anmeldedaten');
362
+ });
363
+
364
+ test('3.2 Error translations are loaded from backend', async ({ page }) => {
365
+ test.skip(!apiAvailable, 'Servers not running');
366
+
367
+ await gotoAndWaitForHydration(page, '/auth/login');
368
+
369
+ const response = await page.request.get(`${FRONTEND_BASE}/api/i18n/errors/de`);
370
+ expect([200, 304]).toContain(response.status());
371
+
372
+ if (response.status() === 200) {
373
+ const data = await response.json();
374
+ expect(data).toHaveProperty('errors');
375
+ expect(data.errors).toHaveProperty('LTNS_0010');
376
+ console.info(` Error translations loaded: ${Object.keys(data.errors).length} codes`);
377
+ }
378
+ });
379
+ });