feat(admin): account governance and user sync pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// 账号治理 + 用户同步 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<void> {
|
||||
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()
|
||||
})
|
||||
Reference in New Issue
Block a user