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.
- package/CHANGELOG.md +9 -0
- package/nuxt-base-template/nuxt.config.ts +1 -1
- package/nuxt-base-template/package-lock.json +170 -12
- package/nuxt-base-template/package.json +7 -6
- package/nuxt-base-template/tests/e2e/auth-feature-order.spec.ts +379 -0
- package/nuxt-base-template/tests/e2e/auth-lifecycle.spec.ts +713 -0
- package/package.json +1 -1
- package/nuxt-base-template/tests/e2e/auth.spec.ts +0 -467
|
@@ -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
|
+
});
|