import { test, expect, type Page } from '@playwright/test' async function fixture(page: Page, options: { enabled?: boolean; fail?: boolean; loggedOut?: boolean } = {}) { const writes: { path: string; body: Record }[] = [] let enabled = options.enabled ?? false const codes = Array.from({ length: 8 }, (_, i) => `AAAAAAAA-BBBBBBBB-CCCCCCCC-${String(i).padStart(8, '0')}`) if (!options.loggedOut) await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'mfa-fixture')) await page.route('**/api/v1/**', async route => { const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', ''), body = request.postData() ? request.postDataJSON() : {} if (request.method() !== 'GET') writes.push({ path, body }) const fail = (code: string, message: string, status = 400) => route.fulfill({ status, json: { code, message, requestId: 'mfa-ui' } }) if (path === '/auth/login') { if (body.password !== 'Correct-password-2026') { await fail('invalid_credentials', '用户名或密码错误', 401); return } if (!body.code) { await fail('mfa_required', '请输入身份验证器动态码或一次性恢复码', 401); return } if (body.code !== '123456' && body.code !== codes[0]) { await fail('mfa_invalid', '验证码错误、已使用或已过期', 401); return } await route.fulfill({ json: { accessToken: 'logged-in-after-mfa', expiresIn: 3600, user: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } } }); return } if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } }); return } if (path === '/auth/mfa' && request.method() === 'GET') { await route.fulfill({ json: { enabled, recoveryRemaining: enabled ? 8 : 0 } }); return } if (path === '/auth/mfa/setup') { if (body.password !== 'Correct-password-2026') { await fail('current_password_invalid', '当前密码不正确'); return } await route.fulfill({ json: { secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP', provisioningUri: 'otpauth://totp/VDI%20Cloud:reader?secret=example', expiresAt: new Date(Date.now() + 600000).toISOString() } }); return } if (path === '/auth/mfa/confirm') { if (options.fail || body.code !== '123456') { await fail('mfa_invalid', '验证码错误或已过期'); return } enabled = true; await route.fulfill({ json: { recoveryCodes: codes } }); return } if (path === '/auth/mfa/recovery') { await route.fulfill({ json: { recoveryCodes: codes } }); return } if (path === '/auth/mfa' && request.method() === 'DELETE') { enabled = false; await route.fulfill({ status: 204 }); return } if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['admin-mfa'], pveConfigured: true } }); return } if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await route.fulfill({ json: { items: [], total: 0, page: 1, pageSize: 100, requestId: 'mfa-ui' } }); return } await route.fulfill({ status: 404, json: { message: '测试接口未定义' } }) }) return { writes, codes } } test('只读人员也可绑定本人验证器,确认后才开启,恢复码下载和关闭后不再显示', async ({ page }, info) => { const calls = await fixture(page); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click() const dialog = page.getByRole('dialog', { name: '本人双重验证', exact: true }) await expect(dialog.getByText('未开启', { exact: true })).toBeVisible() await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026') await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click() await expect(dialog.getByLabel('身份验证器密钥')).toBeVisible() expect(calls.writes.map(x => x.path)).toEqual(['/auth/mfa/setup']) await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('123456'); await dialog.getByRole('button', { name: '验证并开启' }).click() await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible() await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toBeVisible() const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt') await page.screenshot({ path: info.outputPath('mfa-recovery-dark.png'), fullPage: true, animations: 'disabled' }) await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click() await expect(dialog.getByText('已开启', { exact: true })).toBeVisible(); await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toHaveCount(0) expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('JBSWY3') }) test('验证码错误保持未完成绑定,窄屏和浅色布局可用,关闭清除密钥', async ({ page }, info) => { await fixture(page, { fail: true }); await page.goto('/overview'); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 }) await page.getByRole('button', { name: '双重验证', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '本人双重验证' }) await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click() await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('111111'); await dialog.getByRole('button', { name: '验证并开启' }).click() await expect(dialog.getByText(/验证码错误或已过期/)).toBeVisible(); await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toHaveCount(0) expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) await page.screenshot({ path: info.outputPath('mfa-setup-mobile-light.png'), fullPage: true, animations: 'disabled' }) await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click(); await expect(dialog.getByText('未开启', { exact: true })).toBeVisible(); await expect(dialog.getByLabel('身份验证器密钥')).toHaveCount(0) }) test('更新恢复码和关闭需要密码及验证码,取消不提交', async ({ page }) => { const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click() const dialog = page.getByRole('dialog', { name: '本人双重验证' }) await dialog.getByRole('button', { name: '关闭双重验证', exact: true }).click() await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill('123456') await dialog.getByRole('button', { name: '确认关闭双重验证' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0) await dialog.getByRole('button', { name: '确认关闭双重验证' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click() await expect(dialog.getByText('未开启', { exact: true })).toBeVisible(); expect(calls.writes[0]?.body).toEqual({ password: 'Correct-password-2026', code: '123456' }) }) test('重新生成恢复码经过确认后显示新码并支持下载', async ({ page }) => { const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click() const dialog = page.getByRole('dialog', { name: '本人双重验证' }) await dialog.getByRole('button', { name: '重新生成恢复码', exact: true }).click() await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill(calls.codes[0]!) await dialog.getByRole('button', { name: '生成新恢复码', exact: true }).click(); expect(calls.writes).toHaveLength(0) await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click() await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible() expect(calls.writes[0]?.path).toBe('/auth/mfa/recovery'); expect(calls.writes[0]?.body.code).toBe(calls.codes[0]) const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt') }) for (const recovery of [false, true]) test(`登录先验证密码,动态码${recovery ? '使用恢复码' : '错误后重试'}成功前不保存令牌`, async ({ page }) => { const calls = await fixture(page, { loggedOut: true }); await page.goto('/login') await page.getByRole('textbox', { name: '用户名', exact: true }).fill('reader'); await page.getByLabel('密码', { exact: true }).fill('incorrect'); await page.getByRole('button', { name: '登录', exact: true }).click() await expect(page.getByText(/用户名或密码错误/)).toBeVisible(); await expect(page.getByRole('textbox', { name: '登录动态码或恢复码' })).toHaveCount(0) await page.getByLabel('密码', { exact: true }).fill('Correct-password-2026'); await page.getByRole('button', { name: '登录', exact: true }).click() const input = page.getByRole('textbox', { name: '登录动态码或恢复码' }); await expect(input).toBeVisible(); expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBeNull() await input.fill('111111'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page.getByText(/验证码错误、已使用或已过期/)).toBeVisible(); await expect(input).toHaveValue('') await input.fill(recovery ? calls.codes[0]! : '123456'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page).toHaveURL(/\/workbench$/) expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBe('logged-in-after-mfa') })