create-nuxt-base 1.2.0 → 2.1.0
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 +40 -0
- package/nuxt-base-template/.github/workflows/test.yml +90 -0
- package/nuxt-base-template/app/components/Modal/ModalBackupCodes.vue +2 -1
- package/nuxt-base-template/app/components/Upload/TusFileUpload.vue +7 -7
- package/nuxt-base-template/app/interfaces/user.interface.ts +5 -12
- package/nuxt-base-template/app/layouts/default.vue +1 -1
- package/nuxt-base-template/app/middleware/admin.global.ts +2 -2
- package/nuxt-base-template/app/middleware/auth.global.ts +2 -2
- package/nuxt-base-template/app/middleware/guest.global.ts +2 -2
- package/nuxt-base-template/app/pages/app/index.vue +1 -1
- package/nuxt-base-template/app/pages/app/settings/security.vue +54 -43
- package/nuxt-base-template/app/pages/auth/2fa.vue +2 -3
- package/nuxt-base-template/app/pages/auth/forgot-password.vue +2 -1
- package/nuxt-base-template/app/pages/auth/login.vue +6 -4
- package/nuxt-base-template/app/pages/auth/register.vue +85 -61
- package/nuxt-base-template/app/pages/auth/reset-password.vue +2 -1
- package/nuxt-base-template/docs/pages/docs.vue +1 -1
- package/nuxt-base-template/nuxt.config.ts +50 -1
- package/nuxt-base-template/package-lock.json +1311 -2920
- package/nuxt-base-template/package.json +27 -2
- package/nuxt-base-template/playwright.config.ts +1 -1
- package/nuxt-base-template/tests/e2e/auth.spec.ts +467 -0
- package/nuxt-base-template/tests/unit/auth/auth.spec.ts +439 -0
- package/nuxt-base-template/tests/unit/auth/error-translation.spec.ts +279 -0
- package/nuxt-base-template/tests/unit/mocks/auth-client.mock.ts +165 -0
- package/nuxt-base-template/tests/unit/mocks/nuxt-imports.ts +105 -0
- package/nuxt-base-template/tests/unit/setup.ts +56 -0
- package/nuxt-base-template/vitest.config.ts +25 -0
- package/package.json +1 -1
- package/nuxt-base-template/app/components/Transition/TransitionFade.vue +0 -27
- package/nuxt-base-template/app/components/Transition/TransitionFadeScale.vue +0 -27
- package/nuxt-base-template/app/components/Transition/TransitionSlide.vue +0 -12
- package/nuxt-base-template/app/components/Transition/TransitionSlideBottom.vue +0 -12
- package/nuxt-base-template/app/components/Transition/TransitionSlideRevert.vue +0 -12
- package/nuxt-base-template/app/composables/use-better-auth.ts +0 -597
- package/nuxt-base-template/app/composables/use-file.ts +0 -71
- package/nuxt-base-template/app/composables/use-share.ts +0 -38
- package/nuxt-base-template/app/composables/use-tus-upload.ts +0 -278
- package/nuxt-base-template/app/composables/use-tw.ts +0 -1
- package/nuxt-base-template/app/interfaces/upload.interface.ts +0 -58
- package/nuxt-base-template/app/lib/auth-client.ts +0 -229
- package/nuxt-base-template/app/lib/auth-state.ts +0 -206
- package/nuxt-base-template/app/plugins/auth-interceptor.client.ts +0 -151
- package/nuxt-base-template/app/utils/crypto.ts +0 -44
- package/nuxt-base-template/tests/iam.spec.ts +0 -247
- /package/nuxt-base-template/tests/{init.spec.ts → e2e/init.spec.ts} +0 -0
|
@@ -28,8 +28,13 @@
|
|
|
28
28
|
"generate-types": "openapi-ts",
|
|
29
29
|
"preview": "nuxt preview",
|
|
30
30
|
"postinstall": "nuxt prepare",
|
|
31
|
-
"
|
|
31
|
+
"prepare": "simple-git-hooks",
|
|
32
|
+
"test": "npm run test:unit",
|
|
33
|
+
"test:unit": "vitest run",
|
|
34
|
+
"test:unit:watch": "vitest",
|
|
35
|
+
"test:unit:coverage": "vitest run --coverage",
|
|
32
36
|
"test:e2e": "playwright test",
|
|
37
|
+
"test:all": "npm run test:unit && npm run test:e2e",
|
|
33
38
|
"lint": "oxlint app/",
|
|
34
39
|
"lint:fix": "oxlint --fix app/",
|
|
35
40
|
"format": "oxfmt",
|
|
@@ -41,6 +46,7 @@
|
|
|
41
46
|
"@better-auth/passkey": "1.4.10",
|
|
42
47
|
"@hey-api/client-fetch": "0.13.1",
|
|
43
48
|
"@lenne.tech/bug.lt": "latest",
|
|
49
|
+
"@lenne.tech/nuxt-extensions": "1.1.0",
|
|
44
50
|
"@nuxt/image": "2.0.0",
|
|
45
51
|
"@nuxt/ui": "4.3.0",
|
|
46
52
|
"@pinia/nuxt": "0.11.3",
|
|
@@ -60,17 +66,36 @@
|
|
|
60
66
|
"@tailwindcss/typography": "0.5.19",
|
|
61
67
|
"@tailwindcss/vite": "4.1.18",
|
|
62
68
|
"@types/node": "25.0.6",
|
|
69
|
+
"@vitejs/plugin-vue": "^6.0.3",
|
|
70
|
+
"@vue/test-utils": "^2.4.6",
|
|
63
71
|
"dayjs-nuxt": "2.1.11",
|
|
72
|
+
"happy-dom": "^20.3.7",
|
|
64
73
|
"jsdom": "27.4.0",
|
|
74
|
+
"lint-staged": "^16.2.7",
|
|
65
75
|
"nuxt": "4.2.2",
|
|
66
76
|
"oxfmt": "latest",
|
|
67
77
|
"oxlint": "latest",
|
|
68
78
|
"rimraf": "6.1.2",
|
|
79
|
+
"simple-git-hooks": "^2.13.1",
|
|
69
80
|
"tailwindcss": "4.1.18",
|
|
70
|
-
"typescript": "5.9.3"
|
|
81
|
+
"typescript": "5.9.3",
|
|
82
|
+
"vitest": "^3.2.4"
|
|
71
83
|
},
|
|
72
84
|
"engines": {
|
|
73
85
|
"node": ">=22",
|
|
74
86
|
"npm": ">=10"
|
|
87
|
+
},
|
|
88
|
+
"simple-git-hooks": {
|
|
89
|
+
"pre-commit": "npx lint-staged",
|
|
90
|
+
"pre-push": "npm run test:unit"
|
|
91
|
+
},
|
|
92
|
+
"lint-staged": {
|
|
93
|
+
"app/**/*.{ts,vue}": [
|
|
94
|
+
"oxlint --fix",
|
|
95
|
+
"oxfmt"
|
|
96
|
+
],
|
|
97
|
+
"tests/**/*.ts": [
|
|
98
|
+
"oxlint --fix"
|
|
99
|
+
]
|
|
75
100
|
}
|
|
76
101
|
}
|
|
@@ -28,7 +28,7 @@ export default defineConfig<ConfigOptions>({
|
|
|
28
28
|
reporter: 'html',
|
|
29
29
|
/* Retry on CI only */
|
|
30
30
|
retries: isCI ? 2 : 0,
|
|
31
|
-
testDir: './tests',
|
|
31
|
+
testDir: './tests/e2e',
|
|
32
32
|
timeout: isWindows ? 60000 : undefined,
|
|
33
33
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
34
34
|
use: {
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { expect, test } from '@nuxt/test-utils/playwright';
|
|
2
|
+
import type { Page } from '@playwright/test';
|
|
3
|
+
import {
|
|
4
|
+
waitForHydration,
|
|
5
|
+
gotoAndWaitForHydration,
|
|
6
|
+
waitForURLAndHydration,
|
|
7
|
+
fillInput,
|
|
8
|
+
generateTestUser,
|
|
9
|
+
generateTOTP,
|
|
10
|
+
extractTOTPSecret,
|
|
11
|
+
} from '@lenne.tech/nuxt-extensions/testing';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Authentication E2E Tests
|
|
15
|
+
*
|
|
16
|
+
* These tests require running servers:
|
|
17
|
+
* - API: npm start (in nest-server-starter)
|
|
18
|
+
* - Frontend: npm run dev (in nuxt-base-template)
|
|
19
|
+
*
|
|
20
|
+
* Run tests with: npm run test:e2e
|
|
21
|
+
*
|
|
22
|
+
* NOTE: These tests are for manual E2E testing only.
|
|
23
|
+
* For CI/CD pipelines, use vitest unit tests instead.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Project-Specific Test Utilities
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register a new user via UI
|
|
32
|
+
*/
|
|
33
|
+
async function registerUser(
|
|
34
|
+
page: Page,
|
|
35
|
+
user: { email: string; password: string; name: string },
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
await gotoAndWaitForHydration(page, '/auth/register');
|
|
38
|
+
await page.locator('input[name="name"]').waitFor({ state: 'visible', timeout: 10000 });
|
|
39
|
+
|
|
40
|
+
// Fill form fields
|
|
41
|
+
await fillInput(page, 'input[name="name"]', user.name);
|
|
42
|
+
await fillInput(page, 'input[name="email"]', user.email);
|
|
43
|
+
await fillInput(page, 'input[name="password"]', user.password);
|
|
44
|
+
await fillInput(page, 'input[name="confirmPassword"]', user.password);
|
|
45
|
+
|
|
46
|
+
// Click submit button
|
|
47
|
+
await page.getByRole('button', { name: 'Konto erstellen' }).click();
|
|
48
|
+
|
|
49
|
+
// Wait for passkey prompt to appear and dismiss it
|
|
50
|
+
const laterButton = page.getByRole('button', { name: 'Später einrichten' });
|
|
51
|
+
await laterButton.waitFor({ state: 'visible', timeout: 10000 });
|
|
52
|
+
await laterButton.click();
|
|
53
|
+
|
|
54
|
+
await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Login with email and password via UI
|
|
59
|
+
*/
|
|
60
|
+
async function loginWithEmail(
|
|
61
|
+
page: Page,
|
|
62
|
+
email: string,
|
|
63
|
+
password: string,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
await gotoAndWaitForHydration(page, '/auth/login');
|
|
66
|
+
await page.locator('input[name="email"]').waitFor({ state: 'visible', timeout: 10000 });
|
|
67
|
+
|
|
68
|
+
await fillInput(page, 'input[name="email"]', email);
|
|
69
|
+
await fillInput(page, 'input[name="password"]', password);
|
|
70
|
+
|
|
71
|
+
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Logout via UI
|
|
76
|
+
*/
|
|
77
|
+
async function logout(page: Page): Promise<void> {
|
|
78
|
+
// Logout button has aria-label="Logout"
|
|
79
|
+
const logoutButton = page.getByLabel('Logout');
|
|
80
|
+
await logoutButton.waitFor({ state: 'visible', timeout: 5000 });
|
|
81
|
+
await logoutButton.click();
|
|
82
|
+
await page.waitForURL(/\/auth\/login/, { timeout: 5000 });
|
|
83
|
+
await waitForHydration(page);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Enable 2FA and return the TOTP secret
|
|
88
|
+
*
|
|
89
|
+
* NOTE: This function currently has a CORS issue in E2E tests.
|
|
90
|
+
* The authClient sends requests directly to localhost:3000/iam instead of
|
|
91
|
+
* using the Nuxt proxy at /api/iam. This needs to be fixed in nuxt-extensions.
|
|
92
|
+
*/
|
|
93
|
+
async function enable2FA(page: Page, password: string): Promise<string> {
|
|
94
|
+
await gotoAndWaitForHydration(page, '/app/settings/security');
|
|
95
|
+
|
|
96
|
+
// Wait for the 2FA section to be visible
|
|
97
|
+
const enable2FAButton = page.getByRole('button', { name: '2FA aktivieren' });
|
|
98
|
+
await enable2FAButton.waitFor({ state: 'visible', timeout: 10000 });
|
|
99
|
+
|
|
100
|
+
// Fill password field
|
|
101
|
+
const passwordInput = page.locator('input[type="password"]');
|
|
102
|
+
await passwordInput.click();
|
|
103
|
+
await page.keyboard.type(password, { delay: 5 });
|
|
104
|
+
|
|
105
|
+
// Click the button
|
|
106
|
+
await enable2FAButton.click();
|
|
107
|
+
|
|
108
|
+
// Wait for QR code (may take a moment for API call)
|
|
109
|
+
const qrImage = page.locator('img[alt="TOTP QR Code"]');
|
|
110
|
+
await expect(qrImage).toBeVisible({ timeout: 15000 });
|
|
111
|
+
|
|
112
|
+
const qrUrl = await qrImage.getAttribute('src');
|
|
113
|
+
const secret = qrUrl ? extractTOTPSecret(decodeURIComponent(qrUrl)) : null;
|
|
114
|
+
expect(secret).not.toBeNull();
|
|
115
|
+
|
|
116
|
+
// Verify TOTP
|
|
117
|
+
const totpCode = generateTOTP(secret!);
|
|
118
|
+
await fillInput(page, 'input[placeholder="000000"]', totpCode);
|
|
119
|
+
await page.getByRole('button', { name: 'Verifizieren' }).click();
|
|
120
|
+
|
|
121
|
+
// Close backup codes dialog (use specific heading selector)
|
|
122
|
+
await expect(page.getByRole('heading', { name: 'Backup-Codes' })).toBeVisible({ timeout: 10000 });
|
|
123
|
+
await page.keyboard.press('Escape');
|
|
124
|
+
|
|
125
|
+
return secret!;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Cleanup Virtual Authenticator
|
|
130
|
+
*/
|
|
131
|
+
async function cleanupAuthenticator(
|
|
132
|
+
cdpSession: any,
|
|
133
|
+
authenticatorId: string,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
|
136
|
+
await cdpSession.send('WebAuthn.disable');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// =============================================================================
|
|
140
|
+
// API Availability Check
|
|
141
|
+
// =============================================================================
|
|
142
|
+
|
|
143
|
+
let apiAvailable = false;
|
|
144
|
+
|
|
145
|
+
test.beforeAll(async ({ request }) => {
|
|
146
|
+
// Check API and Frontend availability
|
|
147
|
+
let frontendAvailable = false;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const apiResponse = await request.get('http://localhost:3000/');
|
|
151
|
+
apiAvailable = apiResponse.ok();
|
|
152
|
+
} catch {
|
|
153
|
+
apiAvailable = false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const frontendResponse = await request.get('http://localhost:3001/');
|
|
158
|
+
frontendAvailable = frontendResponse.ok();
|
|
159
|
+
} catch {
|
|
160
|
+
frontendAvailable = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!apiAvailable || !frontendAvailable) {
|
|
164
|
+
console.error('');
|
|
165
|
+
console.error('╔══════════════════════════════════════════════════════════════╗');
|
|
166
|
+
console.error('║ E2E TESTS REQUIRE RUNNING SERVERS ║');
|
|
167
|
+
console.error('╠══════════════════════════════════════════════════════════════╣');
|
|
168
|
+
if (!apiAvailable) {
|
|
169
|
+
console.error('║ ✗ API Server (localhost:3000) - NOT RUNNING ║');
|
|
170
|
+
} else {
|
|
171
|
+
console.error('║ ✓ API Server (localhost:3000) - Running ║');
|
|
172
|
+
}
|
|
173
|
+
if (!frontendAvailable) {
|
|
174
|
+
console.error('║ ✗ Frontend (localhost:3001) - NOT RUNNING ║');
|
|
175
|
+
} else {
|
|
176
|
+
console.error('║ ✓ Frontend (localhost:3001) - Running ║');
|
|
177
|
+
}
|
|
178
|
+
console.error('╠══════════════════════════════════════════════════════════════╣');
|
|
179
|
+
console.error('║ NOTE: These tests are for manual E2E testing only. ║');
|
|
180
|
+
console.error('║ For CI/CD pipelines, use vitest unit tests. ║');
|
|
181
|
+
console.error('╚══════════════════════════════════════════════════════════════╝');
|
|
182
|
+
console.error('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
apiAvailable = apiAvailable && frontendAvailable;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// =============================================================================
|
|
189
|
+
// Test 1: All Three Auth Methods with Intermediate Logout/Login
|
|
190
|
+
// =============================================================================
|
|
191
|
+
|
|
192
|
+
test.describe.serial('Test 1: All Auth Methods with Logout/Login', () => {
|
|
193
|
+
const testUser = generateTestUser('auth-all');
|
|
194
|
+
let totpSecret: string | null = null;
|
|
195
|
+
|
|
196
|
+
test('1.1 Register new user', async ({ page }) => {
|
|
197
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
198
|
+
|
|
199
|
+
await registerUser(page, testUser);
|
|
200
|
+
await expect(page).toHaveURL(/\/app/);
|
|
201
|
+
console.info(` Registered: ${testUser.email}`);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('1.2 Logout after registration', async ({ page }) => {
|
|
205
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
206
|
+
|
|
207
|
+
await loginWithEmail(page, testUser.email, testUser.password);
|
|
208
|
+
await waitForURLAndHydration(page, /\/app/);
|
|
209
|
+
await logout(page);
|
|
210
|
+
await expect(page).toHaveURL(/\/auth\/login/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('1.3 Login with Email & Password', async ({ page }) => {
|
|
214
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
215
|
+
|
|
216
|
+
await loginWithEmail(page, testUser.email, testUser.password);
|
|
217
|
+
await waitForURLAndHydration(page, /\/app/, { timeout: 10000 });
|
|
218
|
+
// Use first() to avoid strict mode violation (email appears multiple times in UI)
|
|
219
|
+
await expect(page.getByText(testUser.email).first()).toBeVisible();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('1.4 Enable 2FA', async ({ page }) => {
|
|
223
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
224
|
+
|
|
225
|
+
await loginWithEmail(page, testUser.email, testUser.password);
|
|
226
|
+
await waitForURLAndHydration(page, /\/app/);
|
|
227
|
+
totpSecret = await enable2FA(page, testUser.password);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('1.5 Logout and login with 2FA', async ({ page }) => {
|
|
231
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
232
|
+
test.skip(!totpSecret, 'TOTP secret not available');
|
|
233
|
+
|
|
234
|
+
await loginWithEmail(page, testUser.email, testUser.password);
|
|
235
|
+
await waitForURLAndHydration(page, /\/auth\/2fa/, { timeout: 10000 });
|
|
236
|
+
|
|
237
|
+
const totpCode = generateTOTP(totpSecret!);
|
|
238
|
+
await page.locator('input').fill(totpCode);
|
|
239
|
+
await page.getByRole('button', { name: /verifizieren|bestätigen/i }).click();
|
|
240
|
+
|
|
241
|
+
await waitForURLAndHydration(page, /\/app/, { timeout: 10000 });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('1.6 Add Passkey with Virtual Authenticator', async ({ page, context }) => {
|
|
245
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
246
|
+
test.skip(!totpSecret, 'TOTP secret not available');
|
|
247
|
+
|
|
248
|
+
// Login with 2FA
|
|
249
|
+
await loginWithEmail(page, testUser.email, testUser.password);
|
|
250
|
+
await waitForURLAndHydration(page, /\/auth\/2fa/, { timeout: 10000 });
|
|
251
|
+
|
|
252
|
+
const totpCode = generateTOTP(totpSecret!);
|
|
253
|
+
await page.locator('input').fill(totpCode);
|
|
254
|
+
await page.getByRole('button', { name: /verifizieren|bestätigen/i }).click();
|
|
255
|
+
await waitForURLAndHydration(page, /\/app/, { timeout: 10000 });
|
|
256
|
+
|
|
257
|
+
// Setup Virtual Authenticator
|
|
258
|
+
const cdpSession = await context.newCDPSession(page);
|
|
259
|
+
await cdpSession.send('WebAuthn.enable');
|
|
260
|
+
|
|
261
|
+
const { authenticatorId } = await cdpSession.send(
|
|
262
|
+
'WebAuthn.addVirtualAuthenticator',
|
|
263
|
+
{
|
|
264
|
+
options: {
|
|
265
|
+
protocol: 'ctap2',
|
|
266
|
+
transport: 'internal',
|
|
267
|
+
hasResidentKey: true,
|
|
268
|
+
hasUserVerification: true,
|
|
269
|
+
isUserVerified: true,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
await gotoAndWaitForHydration(page, '/app/settings/security');
|
|
276
|
+
await page.getByRole('button', { name: 'Passkey hinzufügen' }).click();
|
|
277
|
+
await page.getByPlaceholder('Name für den Passkey').fill('Test-Passkey');
|
|
278
|
+
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
|
279
|
+
|
|
280
|
+
await expect(page.getByText('Test-Passkey')).toBeVisible({ timeout: 15000 });
|
|
281
|
+
} finally {
|
|
282
|
+
await cleanupAuthenticator(cdpSession, authenticatorId);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('1.7 Login with Passkey', async ({ page, context }) => {
|
|
287
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
288
|
+
test.skip(!totpSecret, 'TOTP secret not available');
|
|
289
|
+
|
|
290
|
+
// Setup Virtual Authenticator
|
|
291
|
+
const cdpSession = await context.newCDPSession(page);
|
|
292
|
+
await cdpSession.send('WebAuthn.enable');
|
|
293
|
+
|
|
294
|
+
const { authenticatorId } = await cdpSession.send(
|
|
295
|
+
'WebAuthn.addVirtualAuthenticator',
|
|
296
|
+
{
|
|
297
|
+
options: {
|
|
298
|
+
protocol: 'ctap2',
|
|
299
|
+
transport: 'internal',
|
|
300
|
+
hasResidentKey: true,
|
|
301
|
+
hasUserVerification: true,
|
|
302
|
+
isUserVerified: true,
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
// First add passkey while logged in via 2FA
|
|
309
|
+
await loginWithEmail(page, testUser.email, testUser.password);
|
|
310
|
+
await waitForURLAndHydration(page, /\/auth\/2fa/, { timeout: 10000 });
|
|
311
|
+
|
|
312
|
+
const totpCode = generateTOTP(totpSecret!);
|
|
313
|
+
await page.locator('input').fill(totpCode);
|
|
314
|
+
await page.getByRole('button', { name: /verifizieren|bestätigen/i }).click();
|
|
315
|
+
await waitForURLAndHydration(page, /\/app/, { timeout: 10000 });
|
|
316
|
+
|
|
317
|
+
// Add passkey
|
|
318
|
+
await gotoAndWaitForHydration(page, '/app/settings/security');
|
|
319
|
+
await page.getByRole('button', { name: 'Passkey hinzufügen' }).click();
|
|
320
|
+
await page.getByPlaceholder('Name für den Passkey').fill('Login-Passkey');
|
|
321
|
+
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
|
322
|
+
await expect(page.getByText('Login-Passkey')).toBeVisible({ timeout: 15000 });
|
|
323
|
+
|
|
324
|
+
// Logout
|
|
325
|
+
await logout(page);
|
|
326
|
+
|
|
327
|
+
// Login with passkey
|
|
328
|
+
await gotoAndWaitForHydration(page, '/auth/login');
|
|
329
|
+
await page.getByRole('button', { name: 'Mit Passkey anmelden' }).click();
|
|
330
|
+
await waitForURLAndHydration(page, /\/app/, { timeout: 15000 });
|
|
331
|
+
} finally {
|
|
332
|
+
await cleanupAuthenticator(cdpSession, authenticatorId);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// =============================================================================
|
|
338
|
+
// Test 2: Register -> 2FA -> Passkey (without logout)
|
|
339
|
+
// =============================================================================
|
|
340
|
+
|
|
341
|
+
test.describe.serial('Test 2: Register -> 2FA -> Passkey (no logout)', () => {
|
|
342
|
+
const testUser = generateTestUser('2fa-then-passkey');
|
|
343
|
+
|
|
344
|
+
test('Register, enable 2FA, then add Passkey without logout', async ({ page, context }) => {
|
|
345
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
346
|
+
|
|
347
|
+
// Register
|
|
348
|
+
await registerUser(page, testUser);
|
|
349
|
+
|
|
350
|
+
// Enable 2FA
|
|
351
|
+
await enable2FA(page, testUser.password);
|
|
352
|
+
|
|
353
|
+
// Add Passkey
|
|
354
|
+
const cdpSession = await context.newCDPSession(page);
|
|
355
|
+
await cdpSession.send('WebAuthn.enable');
|
|
356
|
+
|
|
357
|
+
const { authenticatorId } = await cdpSession.send(
|
|
358
|
+
'WebAuthn.addVirtualAuthenticator',
|
|
359
|
+
{
|
|
360
|
+
options: {
|
|
361
|
+
protocol: 'ctap2',
|
|
362
|
+
transport: 'internal',
|
|
363
|
+
hasResidentKey: true,
|
|
364
|
+
hasUserVerification: true,
|
|
365
|
+
isUserVerified: true,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
await gotoAndWaitForHydration(page, '/app/settings/security');
|
|
372
|
+
await page.getByRole('button', { name: 'Passkey hinzufügen' }).click();
|
|
373
|
+
await page.getByPlaceholder('Name für den Passkey').fill('After-2FA-Passkey');
|
|
374
|
+
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
|
375
|
+
|
|
376
|
+
await expect(page.getByText('After-2FA-Passkey')).toBeVisible({ timeout: 15000 });
|
|
377
|
+
await expect(page.getByText('2FA ist aktiviert')).toBeVisible();
|
|
378
|
+
} finally {
|
|
379
|
+
await cleanupAuthenticator(cdpSession, authenticatorId);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// =============================================================================
|
|
385
|
+
// Test 3: Register -> Passkey -> 2FA (without logout)
|
|
386
|
+
// =============================================================================
|
|
387
|
+
|
|
388
|
+
test.describe.serial('Test 3: Register -> Passkey -> 2FA (no logout)', () => {
|
|
389
|
+
const testUser = generateTestUser('passkey-then-2fa');
|
|
390
|
+
|
|
391
|
+
test('Register, add Passkey, then enable 2FA without logout', async ({ page, context }) => {
|
|
392
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
393
|
+
|
|
394
|
+
// Register
|
|
395
|
+
await registerUser(page, testUser);
|
|
396
|
+
|
|
397
|
+
// Add Passkey first
|
|
398
|
+
const cdpSession = await context.newCDPSession(page);
|
|
399
|
+
await cdpSession.send('WebAuthn.enable');
|
|
400
|
+
|
|
401
|
+
const { authenticatorId } = await cdpSession.send(
|
|
402
|
+
'WebAuthn.addVirtualAuthenticator',
|
|
403
|
+
{
|
|
404
|
+
options: {
|
|
405
|
+
protocol: 'ctap2',
|
|
406
|
+
transport: 'internal',
|
|
407
|
+
hasResidentKey: true,
|
|
408
|
+
hasUserVerification: true,
|
|
409
|
+
isUserVerified: true,
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
await gotoAndWaitForHydration(page, '/app/settings/security');
|
|
416
|
+
await page.getByRole('button', { name: 'Passkey hinzufügen' }).click();
|
|
417
|
+
await page.getByPlaceholder('Name für den Passkey').fill('Before-2FA-Passkey');
|
|
418
|
+
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
|
419
|
+
|
|
420
|
+
await expect(page.getByText('Before-2FA-Passkey')).toBeVisible({ timeout: 15000 });
|
|
421
|
+
|
|
422
|
+
// Enable 2FA
|
|
423
|
+
await enable2FA(page, testUser.password);
|
|
424
|
+
|
|
425
|
+
// Verify both are active
|
|
426
|
+
await gotoAndWaitForHydration(page, '/app/settings/security');
|
|
427
|
+
await expect(page.getByText('2FA ist aktiviert')).toBeVisible();
|
|
428
|
+
await expect(page.getByText('Before-2FA-Passkey')).toBeVisible();
|
|
429
|
+
} finally {
|
|
430
|
+
await cleanupAuthenticator(cdpSession, authenticatorId);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// =============================================================================
|
|
436
|
+
// Test 4: Error Translations
|
|
437
|
+
// =============================================================================
|
|
438
|
+
|
|
439
|
+
test.describe('Test 4: Error Translations', () => {
|
|
440
|
+
test('4.1 Invalid credentials shows German error message', async ({ page }) => {
|
|
441
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
442
|
+
|
|
443
|
+
await loginWithEmail(page, 'invalid@test.com', 'WrongPassword123!');
|
|
444
|
+
|
|
445
|
+
// Wait for toast notification (use more specific selector)
|
|
446
|
+
const toast = page.locator('li[role="alert"]');
|
|
447
|
+
await expect(toast).toBeVisible({ timeout: 10000 });
|
|
448
|
+
await expect(toast).toContainText('Ungültige Anmeldedaten');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('4.2 Error translations are loaded from backend', async ({ page }) => {
|
|
452
|
+
test.skip(!apiAvailable, 'Servers not running');
|
|
453
|
+
|
|
454
|
+
await gotoAndWaitForHydration(page, '/auth/login');
|
|
455
|
+
|
|
456
|
+
// Check translations endpoint
|
|
457
|
+
const response = await page.request.get('http://localhost:3001/api/i18n/errors/de');
|
|
458
|
+
expect([200, 304]).toContain(response.status());
|
|
459
|
+
|
|
460
|
+
if (response.status() === 200) {
|
|
461
|
+
const data = await response.json();
|
|
462
|
+
expect(data).toHaveProperty('errors');
|
|
463
|
+
expect(data.errors).toHaveProperty('LTNS_0010');
|
|
464
|
+
console.info(` Error translations loaded: ${Object.keys(data.errors).length} codes`);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
});
|