"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。 覆盖的链路(只有真实浏览器才能验证的组合): - 侧边菜单导航与「系统管理」分组 - 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除 - 角色保护:内置 admin 行的禁用/删除按钮禁用;角色 CRUD 全流程 - 操作日志:写操作后可按条件查到记录,展开详情懒加载出脱敏后的请求参数 - 登录日志:能查到当前会话的成功登录 - 权限:普通用户看不到「系统管理」菜单,直连管理页面被守卫弹回首页 **运行前置**(缺一不可):: docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis cd backend && uv run uvicorn app.main:app --port 8000 # 后端 cd frontend && pnpm dev # 前端 管理员密码经环境变量传入(不写死在源码里):: cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py 脚本自建的测试数据(`e2e_` 前缀)在流程内清理;中途失败会残留在开发库, 可用后端集成测试的前缀清理或手工删除。公共工具见 `e2e/common.py`。 """ import os import time from playwright.sync_api import Page, sync_playwright from common import ( click_query, confirm_message_box, fill_filter, open_menu, select_filter_option, table_rows, visible_dialog, BASE_URL, E2EFailure, collect_console_errors, current_path, expect_toast, fail, login, toast_text, wait_toast, ) ADMIN = "admin" E2E_PASSWORD = "E2ePwd@2026" RESET_PASSWORD = "E2eReset@2026" SUFFIX = str(int(time.time())) NEW_USERNAME = f"e2e_user_{SUFFIX}" VIEWER_USERNAME = f"e2e_viewer_{SUFFIX}" NEW_ROLE_CODE = f"e2e_role_{SUFFIX}" # 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取 console_errors: list[str] = [] # ==================== 页面操作辅助 ==================== def create_user_via_ui( page: Page, username: str, real_name: str, expect_success: bool = True ) -> None: """经界面新增用户(角色固定选择「普通用户」)。""" page.click("button:has-text('新增用户')") page.wait_for_timeout(500) dialog = visible_dialog(page) dialog.locator('input[placeholder="登录用户名,创建后不可修改"]').fill(username) dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(E2E_PASSWORD) dialog.locator('input[placeholder="真实姓名"]').fill(real_name) # 角色多选:展开下拉后选择「普通用户」 dialog.locator(".el-select").click() page.wait_for_timeout(300) page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="普通用户").first.click() page.wait_for_timeout(200) dialog.locator(".el-dialog__header").click() # 收起下拉 page.wait_for_timeout(200) dialog.locator(".el-dialog__footer button:has-text('保存')").click() if expect_success: expect_toast(page, "用户已创建", "新增用户") page.wait_for_timeout(600) def find_user_row(page: Page, username: str): """在用户列表中按用户名筛选并返回目标行。""" fill_filter(page, "用户名", username) click_query(page) return table_rows(page).filter(has_text=username).first # ==================== 流程步骤 ==================== def step_user_create(page: Page, admin_password: str) -> None: """登录、菜单导航、新增用户与重名负例。""" login(page, ADMIN, admin_password) open_menu(page, "用户管理", "系统管理") if current_path(page) != "/system/users": fail("菜单导航", f"未进入用户管理页,当前 {page.url}") print("✓ 1. 菜单导航 经侧边菜单进入用户管理") create_user_via_ui(page, NEW_USERNAME, "E2E测试") row = find_user_row(page, NEW_USERNAME) if row.count() == 0: fail("新增用户", f"列表中未找到新建的 {NEW_USERNAME}") print(f"✓ 2. 新增用户 创建 {NEW_USERNAME} 并可在列表查到") create_user_via_ui(page, NEW_USERNAME, "E2E重复", expect_success=False) # 用 wait_toast 而非直读:上一条「用户已创建」提示尚在停留窗口内时, # 直读会命中它造成偶发误判 if "已存在" not in wait_toast(page, "已存在"): fail("重名负例", f"重复用户名未提示已存在,当前提示:{toast_text(page) or '(无)'}") visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click() page.wait_for_timeout(500) print("✓ 3. 重名负例 重复用户名被拒并给出中文提示") def step_user_edit_and_status(page: Page) -> None: """编辑用户、重置密码、启用/禁用。""" row = find_user_row(page, NEW_USERNAME) row.locator("button:has-text('编辑')").click() page.wait_for_timeout(500) dialog = visible_dialog(page) dialog.locator('input[placeholder="真实姓名"]').fill("E2E已改名") dialog.locator(".el-dialog__footer button:has-text('保存')").click() expect_toast(page, "已保存", "编辑用户") row = find_user_row(page, NEW_USERNAME) if "E2E已改名" not in row.inner_text(): fail("编辑用户", "列表未显示修改后的姓名") print("✓ 4. 编辑用户 姓名修改生效") row = find_user_row(page, NEW_USERNAME) row.locator("button:has-text('重置密码')").click() page.wait_for_timeout(500) dialog = visible_dialog(page) dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(RESET_PASSWORD) dialog.locator(".el-dialog__footer button:has-text('确认重置')").click() expect_toast(page, "密码已重置", "重置密码") page.wait_for_timeout(600) print("✓ 5. 重置密码 提示该用户需重新登录") row = find_user_row(page, NEW_USERNAME) row.locator("button:has-text('禁用')").click() page.wait_for_timeout(400) confirm_message_box(page, "禁用") expect_toast(page, "已禁用", "禁用用户") row = find_user_row(page, NEW_USERNAME) if "禁用" not in row.inner_text(): fail("禁用用户", "列表中状态未更新为禁用") row.locator("button:has-text('启用')").click() page.wait_for_timeout(400) confirm_message_box(page, "启用") expect_toast(page, "已启用", "启用用户") print("✓ 6. 启用/禁用 状态切换与后端联动正常") def step_role_management(page: Page) -> None: """角色保护与角色 CRUD。""" open_menu(page, "角色管理", "系统管理") if current_path(page) != "/system/roles": fail("菜单导航", f"未进入角色管理页,当前 {page.url}") admin_row = table_rows(page).filter(has_text="系统管理员").first if not admin_row.locator("button:has-text('禁用')").is_disabled(): fail("角色保护", "内置 admin 角色的禁用按钮未禁用") if not admin_row.locator("button:has-text('删除')").is_disabled(): fail("角色保护", "内置 admin 角色的删除按钮未禁用") print("✓ 7. 角色保护 内置 admin 行的禁用/删除按钮均禁用") page.click("button:has-text('新增角色')") page.wait_for_timeout(500) dialog = visible_dialog(page) dialog.locator('input[placeholder="如 sales_manager,创建后不可修改"]').fill(NEW_ROLE_CODE) dialog.locator('input[placeholder="如 销售主管"]').fill("E2E测试角色") dialog.locator(".el-dialog__footer button:has-text('保存')").click() expect_toast(page, "角色已创建", "新增角色") fill_filter(page, "关键词", NEW_ROLE_CODE) click_query(page) role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first if role_row.count() == 0: fail("新增角色", f"列表中未找到 {NEW_ROLE_CODE}") role_row.locator("button:has-text('编辑')").click() page.wait_for_timeout(500) dialog = visible_dialog(page) dialog.locator('input[placeholder="如 销售主管"]').fill("E2E角色改名") dialog.locator(".el-dialog__footer button:has-text('保存')").click() expect_toast(page, "已保存", "编辑角色") role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first role_row.locator("button:has-text('禁用')").click() page.wait_for_timeout(400) confirm_message_box(page, "禁用") expect_toast(page, "已禁用", "禁用角色") print("✓ 8. 角色管理 新增/编辑/禁用全流程正常") def step_logs_query(page: Page) -> None: """操作日志(含详情懒加载与脱敏断言)与登录日志。""" open_menu(page, "日志查询", "系统管理") if current_path(page) != "/system/logs": fail("菜单导航", f"未进入日志查询页,当前 {page.url}") fill_filter(page, "操作人", ADMIN) select_filter_option(page, "操作", "新增") click_query(page) log_row = table_rows(page).filter(has_text=NEW_USERNAME).first if log_row.count() == 0: fail("操作日志", f"未查到「新增用户 {NEW_USERNAME}」的审计记录") log_row.locator(".el-table__expand-icon").click() # 详情按 id 懒加载:等「加载中…」被替换为内容(含脱敏标记) detail = "" deadline = time.monotonic() + 8 while time.monotonic() < deadline: detail = page.locator(".log-detail__data:visible").first.text_content() or "" if "***" in detail: break page.wait_for_timeout(200) if "***" not in detail: fail("日志脱敏", f"展开详情未加载出已脱敏参数,当前内容:{detail[:80]}") if E2E_PASSWORD in detail: fail("日志脱敏", "请求参数中出现明文密码 —— 脱敏未生效") print("✓ 9. 操作日志 写操作留痕、详情懒加载且密码已脱敏") page.locator(".el-tabs__item:has-text('登录日志')").click() page.wait_for_timeout(800) fill_filter(page, "用户名", ADMIN) click_query(page) if table_rows(page).filter(has_text="成功").count() == 0: fail("登录日志", "未查到当前会话的成功登录记录") print("✓ 10. 登录日志 当前会话登录记录可查") def step_permission_check(page: Page, admin_password: str) -> None: """普通用户菜单隐藏与路由守卫。""" open_menu(page, "用户管理", "系统管理") create_user_via_ui(page, VIEWER_USERNAME, "E2E访客") page.click("text=退出登录") page.wait_for_url("**/login**", timeout=10_000) login(page, VIEWER_USERNAME, E2E_PASSWORD, expected_path="/business/businesses") if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0: fail("菜单权限", "普通用户看到了「系统管理」菜单") print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组") page.goto(f"{BASE_URL}/system/users", wait_until="networkidle") page.wait_for_timeout(800) if current_path(page) != "/business/businesses": fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}") if "需要管理员权限" not in toast_text(page): fail("路由守卫", "直连管理页未给出权限提示") print("✓ 12. 路由守卫 直连 /system/users 被弹回业务数据页") page.click("text=退出登录") page.wait_for_url("**/login**", timeout=10_000) login(page, ADMIN, admin_password) def step_cleanup(page: Page) -> None: """删除测试用户与角色(用 expect_toast 校验,失败不假绿)。""" open_menu(page, "用户管理", "系统管理") for username in (NEW_USERNAME, VIEWER_USERNAME): row = find_user_row(page, username) if row.count() == 0: continue row.locator("button:has-text('删除')").click() page.wait_for_timeout(400) confirm_message_box(page, "删除") expect_toast(page, "已删除", "数据清理") open_menu(page, "角色管理", "系统管理") fill_filter(page, "关键词", NEW_ROLE_CODE) click_query(page) role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first if role_row.count() > 0: role_row.locator("button:has-text('删除')").click() page.wait_for_timeout(400) confirm_message_box(page, "删除") expect_toast(page, "已删除", "数据清理") print("✓ 13. 数据清理 e2e 测试用户与角色已删除") # ==================== 入口 ==================== def run(page: Page) -> None: collect_console_errors(page, console_errors) admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") if not admin_password: fail( "环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)," "示例:E2E_ADMIN_PASSWORD=xxx python e2e/system_management.py", ) step_user_create(page, admin_password) step_user_edit_and_status(page) step_role_management(page) step_logs_query(page) step_permission_check(page, admin_password) step_cleanup(page) if console_errors: print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})") def main() -> int: with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True) page = browser.new_page() page.set_default_timeout(10_000) try: run(page) except E2EFailure as exc: page.screenshot(path="e2e/failure.png", full_page=True) print(f"\n ✗ {exc}") if console_errors: print(f" 控制台错误:{console_errors[-5:]}") print(" 已保存截图 e2e/failure.png") return 1 except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 page.screenshot(path="e2e/failure.png", full_page=True) print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") if console_errors: print(f" 控制台错误:{console_errors[-5:]}") print(" 已保存截图 e2e/failure.png") return 1 finally: browser.close() print("\n全部通过。") return 0 if __name__ == "__main__": raise SystemExit(main())