// 账号治理 + 用户同步 e2e(真实后端 + compose 基础设施): // 账号页:种子用户可见 → 前缀过滤 → 自我保护(自身行角色禁改)→ // 重置密码(生成密码一次性展示/复制)→ 新密码 API 登录 → // 停用(确认框)→ 该用户既有 token 即刻 401 → 状态桶过滤联动; // 同步页:手动补拉(mock 源默认健康场景)→ 状态刷新(终态+计数+水位)→ // 运行历史行点击 → 失败明细抽屉(成功批次空明细——admin e2e 无法 // 触达后端 mock provider 注入坏行场景,失败明细非空链路由 API 侧 // e2e 覆盖,此处断言空抽屉 + 真实批次计数)。 // 治理操作用独立种子账号 gov-e2e(与 albums 套件的 staff-e2e 隔离,互不影响)。 // 选择器一律 data-test(test.use testIdAttribute),与文案解耦。 import { expect, test, type Page } from '@playwright/test' import { adminCredentials, loginAsAdmin, seedFixture } from './helpers' test.use({ testIdAttribute: 'data-test', permissions: ['clipboard-read', 'clipboard-write'] }) const GOV_ACCOUNT = 'gov-e2e' test.beforeAll(() => { // 幂等种子治理目标账号(占位密码不可登录;本套件经重置密码赋予真实密码) seedFixture('staff', GOV_ACCOUNT, 'Gov E2E') }) /** 进入账号管理页(已登录态) */ async function gotoUsers(page: Page): Promise { await page.goto('/#/system/users') await expect(page.getByTestId('users-table')).toBeVisible() } /** 账号表内定位目标行 */ function userRow(page: Page, account: string) { return page.getByTestId('users-table').locator('tr', { hasText: account }) } test('账号页:种子用户可见 → 前缀过滤 → 自我保护(自身行角色禁改)', async ({ page }) => { await loginAsAdmin(page) await gotoUsers(page) const { account } = adminCredentials() await expect(userRow(page, account)).toBeVisible() await expect(userRow(page, GOV_ACCOUNT)).toBeVisible() // 前缀过滤:查 gov-e2e → 目标行保留、admin 行消失 await page.getByTestId('users-query-input').fill(GOV_ACCOUNT) await page.getByTestId('users-search-btn').click() await expect(userRow(page, GOV_ACCOUNT)).toBeVisible() await expect(userRow(page, account)).toHaveCount(0) // 自我保护:admin 自身行的编辑对话框中角色选择禁用(服务端 400 的 UI 前置) await page.getByTestId('users-query-input').fill('') await page.getByTestId('users-search-btn').click() await userRow(page, account).getByTestId('user-edit-btn').click() await expect(page.getByTestId('user-edit-dialog')).toBeVisible() // ElSelect 禁用态落在内层 .el-select__wrapper 的 is-disabled class //(根 div 与 toBeDisabled 均不适配) await expect(page.getByTestId('user-edit-role').locator('.el-select__wrapper')).toHaveClass( /is-disabled/ ) await page.getByTestId('user-edit-cancel').click() await expect(page.getByTestId('user-edit-dialog')).toBeHidden() }) test('重置密码(生成)→ 新密码可登录 → 停用 → 既有 token 401 → 状态过滤联动', async ({ page }) => { await loginAsAdmin(page) await gotoUsers(page) // 定位治理目标行(过滤收敛到单行) await page.getByTestId('users-query-input').fill(GOV_ACCOUNT) await page.getByTestId('users-search-btn').click() const govRow = userRow(page, GOV_ACCOUNT) await expect(govRow).toBeVisible() // 上一轮运行可能已停用(锁定用户无法登录)——先解锁恢复基线,保证幂等重跑 if (await govRow.getByTestId('user-unlock-btn').isVisible()) { await govRow.getByTestId('user-unlock-btn').click() await page.locator('.el-message-box').getByRole('button', { name: '确定' }).click() await expect(govRow.getByTestId('user-deactivate-btn')).toBeVisible({ timeout: 20_000 }) } // ── 重置密码:生成模式(默认)→ 一次性展示 + 复制 ── await govRow.getByTestId('user-reset-btn').click() await expect(page.getByTestId('user-reset-dialog')).toBeVisible() await page.getByTestId('user-reset-submit').click() await expect(page.getByTestId('user-reset-result')).toBeVisible() const newPassword = ((await page.getByTestId('user-reset-password').textContent()) ?? '').trim() expect(newPassword.length).toBeGreaterThanOrEqual(8) await page.getByTestId('user-reset-copy').click() // 127.0.0.1 是安全上下文:剪贴板内容与展示一致 const clipped = await page.evaluate(() => navigator.clipboard.readText()) expect(clipped).toBe(newPassword) await page.getByTestId('user-reset-close').click() await expect(page.getByTestId('user-reset-dialog')).toBeHidden() // ── 新密码 API 登录(重置已撤销占位凭据语义)── const loginRes = await page.request.post('/v1/auth/password', { data: { account: GOV_ACCOUNT, password: newPassword } }) expect(loginRes.ok()).toBeTruthy() const { sessionToken } = (await loginRes.json()) as { sessionToken: string } const authed = { Authorization: `Bearer ${sessionToken}` } const before = await page.request.get('/v1/albums', { headers: authed }) expect(before.status()).toBe(200) // ── 停用(确认框:会话撤销警告)→ 行内切换为「解锁」── await govRow.getByTestId('user-deactivate-btn').click() await page.locator('.el-message-box').getByRole('button', { name: '确定' }).click() await expect(govRow.getByTestId('user-unlock-btn')).toBeVisible({ timeout: 20_000 }) // 停用即撤会话:该用户既有 token 即刻 401 const after = await page.request.get('/v1/albums', { headers: authed }) expect(after.status()).toBe(401) // ── 状态桶过滤联动:locked + gov 前缀 → 可见;locked + staff-e2e → 空 ── await page.getByTestId('users-status-select').click() await page.locator('.el-select-dropdown__item:visible', { hasText: '本地锁定' }).click() await page.getByTestId('users-search-btn').click() await expect(userRow(page, GOV_ACCOUNT)).toBeVisible() await page.getByTestId('users-query-input').fill('staff-e2e') await page.getByTestId('users-search-btn').click() await expect(page.getByTestId('users-table').locator('tbody tr')).toHaveCount(0) }) test('同步页:手动补拉 → 终态计数刷新 → 运行历史行点击打开失败明细抽屉', async ({ page }) => { await loginAsAdmin(page) await page.goto('/#/system/user-sync') await expect(page.getByTestId('sync-status-card')).toBeVisible() await expect(page.getByTestId('sync-runs-table')).toBeVisible() // 手动补拉(202 受理 → 页内轮询至批次终态;idle 是受理前的初值,须等到 // 终态标签而非「非 running」——受理与批次落账之间有秒级窗口) await page.getByTestId('sync-trigger-btn').click() await expect .poll(async () => page.getByTestId('sync-state-tag').getAttribute('data-state'), { timeout: 30_000 }) .toMatch(/^(success|partial)$/) // mock 源默认健康场景:首轮 added=3 / 已有水位时增量幂等,终态均应为 success // 状态刷新:最近批次计数 chips 可见、源水位已提交(非 '-') await expect(page.getByTestId('sync-counts')).toBeVisible() const watermark = (await page.getByTestId('sync-source-version').textContent()) ?? '' expect(watermark.trim()).not.toBe('-') // 运行历史:首行为本手动批次 const firstRun = page.getByTestId('sync-runs-table').locator('tbody tr').first() await expect(firstRun).toContainText('手动') // 行点击 → 失败明细抽屉(成功批次 → 空明细) await firstRun.click() await expect(page.getByTestId('sync-errors-drawer')).toBeVisible() await expect(page.getByTestId('sync-errors-empty').first()).toBeVisible() })