- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款), 守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向 - 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部 - 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围 - 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单 - 新增 role_home_flow e2e,更新 system_management 普通用户断言 Co-Authored-By: Claude Code <noreply@anthropic.com>
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。
|
||
|
||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||
|
||
- 侧边菜单导航与「系统管理」分组
|
||
- 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除
|
||
- 角色保护:内置 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())
|