import type { APIRequestContext, APIResponse, Page } from '@playwright/test' import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' import { adminAccessToken, chooseSelectOption, confirmMessageBox, envelopeData, fillFormItem, loginAsAdmin, tableRowByText, visibleDialog, } from './helpers' test.describe.configure({ mode: 'serial' }) test.use({ trace: 'off', video: 'off' }) const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-12).toLowerCase() const data = { parentName: `${runPrefix}-组织父部门`, editedParentName: `${runPrefix}-已编辑父部门`, parentCode: `APE2E_P_${suffix}`.toUpperCase().slice(0, 32), childName: `${runPrefix}-组织子部门`, childCode: `APE2E_C_${suffix}`.toUpperCase().slice(0, 32), grandchildName: `${runPrefix}-组织孙部门`, grandchildCode: `APE2E_G_${suffix}`.toUpperCase().slice(0, 32), roleName: `${runPrefix}-范围角色`, roleCode: `ape2e_scope_${suffix}`.slice(0, 48), username: `ape2e_state_${suffix}`.slice(0, 32), displayName: `${runPrefix}-状态学员`, editedDisplayName: `${runPrefix}-已编辑状态学员`, initialPassword: `Init@${suffix.slice(-6)}Aa1`, } type ApiRecord = Record const auth = (token: string) => ({ Authorization: `Bearer ${token}` }) const record = (value: unknown): ApiRecord => value && typeof value === 'object' ? value as ApiRecord : {} const versionOf = (value: ApiRecord) => Number(value.version ?? value.dataVersion ?? 0) async function responseJson(response: APIResponse) { return response.json().catch(() => ({})) } function listRecords(value: unknown): ApiRecord[] { const payload = envelopeData(value) if (Array.isArray(payload)) return payload.map(record) const root = record(payload) const source = root.records ?? root.items ?? root.departments ?? root.roles ?? root.users return Array.isArray(source) ? source.map(record) : [] } async function apiDepartments(request: APIRequestContext, token: string) { const response = await request.get('/api/auth/v1/departments', { headers: auth(token) }) expect(response.ok(), `读取部门失败:HTTP ${response.status()}`).toBeTruthy() return listRecords(await responseJson(response)) } async function apiDepartment(request: APIRequestContext, token: string, code: string) { return (await apiDepartments(request, token)).find((item) => String(item.code) === code) } async function apiRole(request: APIRequestContext, token: string) { const response = await request.get('/api/auth/v1/roles', { headers: auth(token), params: { keyword: data.roleCode, page: 1, size: 100 }, }) expect(response.ok(), `读取角色失败:HTTP ${response.status()}`).toBeTruthy() return listRecords(await responseJson(response)).find((item) => String(item.code) === data.roleCode) } async function apiUser(request: APIRequestContext, token: string) { const response = await request.get('/api/auth/v1/users', { headers: auth(token), params: { keyword: data.username, page: 1, size: 100 }, }) expect(response.ok(), `读取用户失败:HTTP ${response.status()}`).toBeTruthy() return listRecords(await responseJson(response)).find((item) => String(item.username) === data.username) } async function apiRolePermissions(request: APIRequestContext, token: string, roleId: string) { const response = await request.get(`/api/auth/v1/permissions/roles/${roleId}`, { headers: auth(token) }) expect(response.ok(), `读取角色权限失败:HTTP ${response.status()}`).toBeTruthy() const payload = record(envelopeData(await responseJson(response))) const source = payload.permissions ?? payload.permissionCodes ?? payload.codes return Array.isArray(source) ? source.map(String) : [] } async function cleanupCreatedData(request: APIRequestContext, token: string) { const result: Record = {} const user = await apiUser(request, token) if (user) { const response = await request.delete(`/api/auth/v1/users/${String(user.id)}`, { headers: auth(token), data: { version: versionOf(user) }, }) result.user = String(response.status()) } else result.user = 'absent' const role = await apiRole(request, token) if (role) { const response = await request.delete(`/api/auth/v1/roles/${String(role.id)}`, { headers: auth(token), data: { version: versionOf(role) }, }) result.role = String(response.status()) } else result.role = 'absent' for (const [label, code] of [ ['grandchild', data.grandchildCode], ['child', data.childCode], ['parent', data.parentCode], ] as const) { const department = await apiDepartment(request, token, code) if (!department) { result[label] = 'absent' continue } const response = await request.post(`/api/auth/v1/departments/${String(department.id)}/migrate-and-delete`, { headers: auth(token), data: { version: versionOf(department) }, }) result[label] = String(response.status()) } return result } async function search(page: Page, placeholder: string, keyword: string) { const input = page.getByPlaceholder(placeholder) await expect(input).toBeVisible() await input.fill(keyword) await page.getByRole('button', { name: '搜索', exact: true }).click() } async function openDepartment(page: Page, code: string) { await search(page, '请输入部门、编码或负责人', code) const row = tableRowByText(page, code) await expect(row).toBeVisible() return row } async function createDepartment(page: Page, parentCode: string | null, name: string, code: string) { if (parentCode) { const parentRow = await openDepartment(page, parentCode) await parentRow.getByRole('button', { name: '新增下级', exact: true }).click() } else { await page.getByRole('button', { name: '新增部门', exact: true }).click() } const dialog = visibleDialog(page) await fillFormItem(dialog, '部门名称', name) await fillFormItem(dialog, '部门编码', code) await fillFormItem(dialog, '部门职责', `${runPrefix} 页面提交的组织层级测试数据`) await dialog.getByRole('button', { name: '创建部门' }).click() await expect(dialog).toBeHidden() await expect(await openDepartment(page, code)).toContainText(name) } async function logout(page: Page) { await page.locator('.user-menu-trigger').click() await page.getByRole('menuitem', { name: /退出登录/ }).click() await confirmMessageBox(page, '退出登录') await expect(page).toHaveURL(/\/login(?:\?|$)/) } async function selectStudentIdentity(page: Page) { await page.locator('[data-role-code="student"]').click() const picker = page.locator('.login-identity-picker') await expect(picker).toBeVisible() await picker.click() const option = page.locator('.login-identity-popper .el-select-dropdown__item') .filter({ hasText: data.editedDisplayName }).first() await expect(option).toBeVisible() await option.click() } async function clearBrowserSession(page: Page) { await page.evaluate(() => { window.localStorage.clear() window.sessionStorage.clear() }) await page.context().clearCookies() } test('IAM 扩展页面闭环:层级迁移、范围授权、停启登录与页面删除', async ({ page, request }, testInfo) => { test.slow() const token = await adminAccessToken(request) await cleanupCreatedData(request, token) await loginAsAdmin(page) let primaryError: unknown try { await test.step('父子孙部门全部页面创建,父部门编辑后刷新与 API 均持久化', async () => { await page.goto('/organization/departments') await createDepartment(page, null, data.parentName, data.parentCode) await createDepartment(page, data.parentCode, data.childName, data.childCode) await createDepartment(page, data.childCode, data.grandchildName, data.grandchildCode) const parentRow = await openDepartment(page, data.parentCode) await parentRow.getByRole('button', { name: '编辑', exact: true }).click() const dialog = visibleDialog(page) await fillFormItem(dialog, '部门名称', data.editedParentName) await fillFormItem(dialog, '部门职责', `${runPrefix} 已通过页面编辑并保存`) await dialog.getByRole('button', { name: '保存修改' }).click() await expect(dialog).toBeHidden() await page.reload() await expect(await openDepartment(page, data.parentCode)).toContainText(data.editedParentName) const parent = await apiDepartment(request, token, data.parentCode) const child = await apiDepartment(request, token, data.childCode) const grandchild = await apiDepartment(request, token, data.grandchildCode) expect(parent?.name).toBe(data.editedParentName) expect(String(child?.parentId)).toBe(String(parent?.id)) expect(String(grandchild?.parentId)).toBe(String(child?.id)) await captureScreenshot(page, testInfo, '01-department-hierarchy-edited-and-reloaded') }) await test.step('含启用孙部门的子部门页面级联停用,并逐级页面启用', async () => { const childRow = await openDepartment(page, data.childCode) await childRow.getByRole('button', { name: '停用', exact: true }).click() await confirmMessageBox(page, '级联停用') await expect(await openDepartment(page, data.childCode)).toContainText('已停用') expect((await apiDepartment(request, token, data.childCode))?.enabled).toBe(false) expect((await apiDepartment(request, token, data.grandchildCode))?.enabled).toBe(false) await (await openDepartment(page, data.childCode)).getByRole('button', { name: '启用', exact: true }).click() await expect(await openDepartment(page, data.childCode)).toContainText('已启用') await (await openDepartment(page, data.grandchildCode)).getByRole('button', { name: '启用', exact: true }).click() await expect(await openDepartment(page, data.grandchildCode)).toContainText('已启用') expect((await apiDepartment(request, token, data.childCode))?.enabled).toBe(true) expect((await apiDepartment(request, token, data.grandchildCode))?.enabled).toBe(true) await page.reload() await expect(await openDepartment(page, data.grandchildCode)).toContainText('已启用') await captureScreenshot(page, testInfo, '02-department-cascade-disabled-and-reenabled') }) await test.step('角色通过页面创建后改为自定义数据范围并选择父部门,刷新与 API 持久化', async () => { await page.goto('/organization/roles') await page.getByRole('button', { name: '新增角色', exact: true }).click() let dialog = visibleDialog(page) await fillFormItem(dialog, '角色名称', data.roleName) await fillFormItem(dialog, '角色编码', data.roleCode) await fillFormItem(dialog, '界面简称', '范围') await fillFormItem(dialog, '角色说明', `${runPrefix} 自定义范围测试角色`) await dialog.getByRole('button', { name: '创建角色' }).click() await search(page, '请输入角色名称、编码或说明', data.roleCode) let row = tableRowByText(page, data.roleCode) await expect(row).toBeVisible() await row.getByRole('button', { name: '编辑', exact: true }).click() dialog = visibleDialog(page) await chooseSelectOption(page, dialog, '数据范围', '自定义范围') await chooseSelectOption(page, dialog, '自定义部门范围', data.childName) await fillFormItem(dialog, '角色说明', `${runPrefix} CUSTOM 范围已保存`) await dialog.getByRole('button', { name: '保存修改' }).click() await page.reload() await search(page, '请输入角色名称、编码或说明', data.roleCode) row = tableRowByText(page, data.roleCode) await expect(row).toContainText('自定义范围') await expect(row).toContainText('CUSTOM 范围已保存') const role = await apiRole(request, token) const child = await apiDepartment(request, token, data.childCode) expect(role?.dataScope).toBe('CUSTOM') expect((role?.departmentScopeIds as unknown[]).map(String)).toEqual([String(child?.id)]) await captureScreenshot(page, testInfo, '03-role-custom-scope-saved-and-reloaded') }) await test.step('权限页完成整组全选、跨组多选和整组取消,两次保存刷新均与 API 一致', async () => { const row = tableRowByText(page, data.roleCode) await row.getByRole('button', { name: '配置权限', exact: true }).click() await expect(page).toHaveURL(/\/organization\/permissions\?role=/) const groups = page.locator('.permission-group') await expect(groups.first()).toBeVisible() expect(await groups.count()).toBeGreaterThan(1) const firstGroup = groups.nth(0) const secondGroup = groups.nth(1) const firstCodes = (await firstGroup.locator('.permission-item code').allTextContents()).map((item) => item.trim()) const extraItem = secondGroup.locator('.permission-item').first() const extraCode = (await extraItem.locator('code').innerText()).trim() await firstGroup.getByRole('button', { name: '全选本组' }).click() await extraItem.click() await page.getByRole('button', { name: '保存权限配置' }).click() await expect(page.getByText(/权限已保存并立即生效/).last()).toBeVisible() const role = await apiRole(request, token) const firstSaved = await apiRolePermissions(request, token, String(role?.id)) expect(firstSaved).toEqual(expect.arrayContaining([...firstCodes, extraCode])) await page.reload() await expect(page.locator('.role-selector > button.active')).toContainText(data.roleName) for (const code of [...firstCodes, extraCode]) { await expect(page.locator('.permission-item').filter({ hasText: code }).first()).toHaveAttribute('aria-checked', 'true') } await page.locator('.permission-group').first().getByRole('button', { name: '清空' }).click() await page.getByRole('button', { name: '保存权限配置' }).click() await expect(page.getByText(/权限已保存并立即生效/).last()).toBeVisible() const secondSaved = await apiRolePermissions(request, token, String(role?.id)) expect(secondSaved).toContain(extraCode) firstCodes.forEach((code) => expect(secondSaved).not.toContain(code)) await page.reload() await expect(page.locator('.permission-item').filter({ hasText: extraCode }).first()).toHaveAttribute('aria-checked', 'true') for (const code of firstCodes) { await expect(page.locator('.permission-item').filter({ hasText: code }).first()).toHaveAttribute('aria-checked', 'false') } await captureScreenshot(page, testInfo, '04-permission-group-save-clear-and-reload') }) await test.step('用户页面创建并分配学员与自定义角色,编辑后刷新和 API 持久化', async () => { await page.goto('/organization/users') await page.getByRole('button', { name: '新增用户', exact: true }).click() let dialog = visibleDialog(page) await fillFormItem(dialog, '姓名', data.displayName) await fillFormItem(dialog, '登录账号', data.username) await chooseSelectOption(page, dialog, '所属部门', data.childName) await fillFormItem(dialog, '初始密码', data.initialPassword) await dialog.locator('.el-checkbox').filter({ hasText: '学员' }).first().click() await dialog.locator('.el-checkbox').filter({ hasText: data.roleName }).first().click() await chooseSelectOption(page, dialog, '登录默认身份', '学员') await dialog.getByRole('button', { name: '创建用户' }).click() await search(page, '请输入姓名、账号、部门或角色', data.username) let row = tableRowByText(page, data.username) await expect(row).toBeVisible() await row.getByRole('button', { name: '编辑', exact: true }).click() dialog = visibleDialog(page) await fillFormItem(dialog, '姓名', data.editedDisplayName) await dialog.getByRole('button', { name: '保存修改' }).click() await page.reload() await search(page, '请输入姓名、账号、部门或角色', data.username) row = tableRowByText(page, data.username) await expect(row).toContainText(data.editedDisplayName) const user = await apiUser(request, token) const role = await apiRole(request, token) expect(user?.displayName).toBe(data.editedDisplayName) expect((user?.roleIds as unknown[]).map(String)).toContain(String(role?.id)) await captureScreenshot(page, testInfo, '05-user-created-edited-and-reloaded') }) await test.step('有关联用户时角色页面删除禁用且正式 API 拒绝,角色页面停启可持久化', async () => { await page.goto('/organization/roles') await search(page, '请输入角色名称、编码或说明', data.roleCode) let row = tableRowByText(page, data.roleCode) await expect(row.getByRole('button', { name: '删除', exact: true })).toBeDisabled() let role = await apiRole(request, token) const blockedDelete = await request.delete(`/api/auth/v1/roles/${String(role?.id)}`, { headers: auth(token), data: { version: versionOf(role ?? {}) }, }) expect(blockedDelete.status()).toBe(409) await row.getByRole('button', { name: '停用', exact: true }).click() await confirmMessageBox(page, '确认') await expect(tableRowByText(page, data.roleCode)).toContainText('已停用') role = await apiRole(request, token) expect(role?.enabled).toBe(false) await page.reload() await search(page, '请输入角色名称、编码或说明', data.roleCode) row = tableRowByText(page, data.roleCode) await expect(row).toContainText('已停用') await row.getByRole('button', { name: '启用', exact: true }).click() await confirmMessageBox(page, '确认') await expect(tableRowByText(page, data.roleCode)).toContainText('已启用') expect((await apiRole(request, token))?.enabled).toBe(true) await page.reload() await search(page, '请输入角色名称、编码或说明', data.roleCode) await expect(tableRowByText(page, data.roleCode)).toContainText('已启用') await captureScreenshot(page, testInfo, '06-linked-role-delete-blocked-and-status-restored') }) await test.step('删除子部门时页面迁移关联用户与直属孙部门,刷新树和 API 均无悬空引用', async () => { await page.goto('/organization/departments') const childRow = await openDepartment(page, data.childCode) await expect(childRow).toContainText('1') await childRow.getByRole('button', { name: '删除', exact: true }).click() const dialog = visibleDialog(page) await expect(dialog).toContainText('关联用户') await expect(dialog).toContainText('直属下级') const migrationSelects = dialog.locator('.el-form-item').filter({ hasText: /迁入部门|新上级/ }).locator('.el-select') await expect(migrationSelects).toHaveCount(2) await expect(migrationSelects.nth(0)).toContainText(data.editedParentName) await expect(migrationSelects.nth(1)).toContainText(data.editedParentName) await captureScreenshot(page, testInfo, '07-department-delete-migration-confirmation') await dialog.getByRole('button', { name: '确认删除并迁移' }).click() await expect(dialog).toBeHidden() await page.reload() await search(page, '请输入部门、编码或负责人', data.childCode) await expect(tableRowByText(page, data.childCode)).toHaveCount(0) await expect(page.locator('.department-scope-tree')).not.toContainText(data.childName) await expect(page.locator('.department-scope-tree')).toContainText(data.grandchildName) const parent = await apiDepartment(request, token, data.parentCode) const child = await apiDepartment(request, token, data.childCode) const grandchild = await apiDepartment(request, token, data.grandchildCode) const user = await apiUser(request, token) expect(child).toBeUndefined() expect(String(grandchild?.parentId)).toBe(String(parent?.id)) expect(String(user?.departmentId)).toBe(String(parent?.id)) const role = await apiRole(request, token) const migratedScopeIds = (role?.departmentScopeIds as unknown[]).map(String) expect(migratedScopeIds).toEqual([String(parent?.id)]) await page.goto(`/organization/roles`) await search(page, '请输入角色名称、编码或说明', data.roleCode) await tableRowByText(page, data.roleCode).getByRole('button', { name: '编辑', exact: true }).click() const scopeDialog = visibleDialog(page) await expect(scopeDialog.getByText(data.editedParentName, { exact: true })).toBeVisible() await expect(scopeDialog.getByText(data.childName, { exact: true })).toHaveCount(0) await captureScreenshot(page, testInfo, '08-role-scope-migrated-without-deleted-department') await scopeDialog.getByRole('button', { name: '取消' }).click() await page.goto('/organization/departments') await search(page, '请输入部门、编码或负责人', data.grandchildCode) await expect(tableRowByText(page, data.grandchildCode)).toBeVisible() await captureScreenshot(page, testInfo, '09-department-migration-tree-and-table-reloaded') }) await test.step('用户页面停用后登录 API 明确拒绝且身份选择器隐藏,启用后页面登录恢复', async () => { await page.goto('/organization/users') await search(page, '请输入姓名、账号、部门或角色', data.username) let row = tableRowByText(page, data.username) await row.getByRole('button', { name: '停用', exact: true }).click() await confirmMessageBox(page, '确认停用') await expect(tableRowByText(page, data.username)).toContainText('已停用') expect((await apiUser(request, token))?.enabled).toBe(false) await page.reload() await search(page, '请输入姓名、账号、部门或角色', data.username) await expect(tableRowByText(page, data.username)).toContainText('已停用') const user = await apiUser(request, token) await logout(page) const rejected = await request.post('/api/auth/v1/auth/login', { data: { roleCode: 'student', userId: String(user?.id), password: data.initialPassword, rememberMe: false }, }) // 公开登录接口故意不泄露“账号已停用”,与账号/密码/身份不匹配统一返回 401。 expect(rejected.status()).toBe(401) await page.locator('[data-role-code="student"]').click() const picker = page.locator('.login-identity-picker') await expect(picker).toBeVisible() await picker.click() await expect(page.locator('.login-identity-popper .el-select-dropdown__item').filter({ hasText: data.editedDisplayName })).toHaveCount(0) await captureScreenshot(page, testInfo, '10-disabled-user-login-rejected-and-hidden') await loginAsAdmin(page) await page.goto('/organization/users') await search(page, '请输入姓名、账号、部门或角色', data.username) row = tableRowByText(page, data.username) await row.getByRole('button', { name: '启用', exact: true }).click() await confirmMessageBox(page, '确认启用') await expect(tableRowByText(page, data.username)).toContainText('正常') expect((await apiUser(request, token))?.enabled).toBe(true) await logout(page) await selectStudentIdentity(page) await page.locator('input[name="password"]').fill(data.initialPassword) await page.getByRole('button', { name: '登录系统' }).click() await expect(page).toHaveURL(/\/change-password(?:\?|$)/) await captureScreenshot(page, testInfo, '11-reenabled-user-page-login-restored') await clearBrowserSession(page) await loginAsAdmin(page) }) await test.step('用户页面确认删除后 API 无记录,角色解除关联后通过页面最终删除', async () => { await page.goto('/organization/users') await search(page, '请输入姓名、账号、部门或角色', data.username) let row = tableRowByText(page, data.username) await row.getByRole('button', { name: '删除', exact: true }).click() await expect(page.locator('.el-message-box:visible')).toContainText(data.username) await confirmMessageBox(page, '确认删除') await expect(tableRowByText(page, data.username)).toHaveCount(0) expect(await apiUser(request, token)).toBeUndefined() await captureScreenshot(page, testInfo, '12-user-page-delete-confirmed') await page.goto('/organization/roles') await search(page, '请输入角色名称、编码或说明', data.roleCode) row = tableRowByText(page, data.roleCode) await expect(row.getByRole('button', { name: '删除', exact: true })).toBeEnabled() await row.getByRole('button', { name: '删除', exact: true }).click() await confirmMessageBox(page, '删除角色') await expect(tableRowByText(page, data.roleCode)).toHaveCount(0) expect(await apiRole(request, token)).toBeUndefined() await captureScreenshot(page, testInfo, '13-unlinked-role-page-delete-confirmed') }) } catch (error) { primaryError = error } finally { const cleanup = await cleanupCreatedData(request, token).catch((error) => ({ cleanupError: String(error) })) await attachJson(testInfo, 'cleanup-result', cleanup) } if (primaryError) throw primaryError expect(await apiUser(request, token)).toBeUndefined() expect(await apiRole(request, token)).toBeUndefined() expect(await apiDepartment(request, token, data.grandchildCode)).toBeUndefined() expect(await apiDepartment(request, token, data.childCode)).toBeUndefined() expect(await apiDepartment(request, token, data.parentCode)).toBeUndefined() })