"""浏览器端到端验证:登录 + 图形验证码。 验证的是**只有真实浏览器才能暴露**的问题: - 路由守卫是否真的拦住了未登录访问 - 验证码图片能否经 Vite 代理正常渲染(跨前后端的完整链路) - 登录失败后验证码是否自动刷新 —— 后端用 GETDEL 消费了验证码, 前端不刷新的话第二次提交必然失败,而这个问题在人工测试时极易被忽略 - 刷新页面能否用 /me 恢复会话 - 登出后令牌是否立即失效 **运行前置**(缺一不可):: 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 && python e2e/login_flow.py 依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。 公共工具见 `e2e/common.py`。 """ import os from playwright.sync_api import Page, sync_playwright from common import ( BASE_URL, E2EFailure, collect_console_errors, current_path, fail, redis_get, toast_text, ) USERNAME = "admin" # 与 system_management 同一约定:开发库 admin 密码只能从环境变量传入。 # 不保留仓库内默认值,避免历史开发口令随代码扩散或在误用旧库时直接登录。 PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD") if not PASSWORD: raise SystemExit("请先设置 E2E_ADMIN_PASSWORD,脚本不再提供默认管理员密码") def run(page: Page) -> None: captured_captcha_ids: list[str] = [] console_errors = collect_console_errors(page) def on_response(response) -> None: if "/api/v1/auth/captcha" not in response.url or response.status != 200: return try: captured_captcha_ids.append(response.json()["data"]["captcha_id"]) except Exception: # 页面导航后响应体已不可读(Playwright 会抛协议错误)。 # 事件回调里未捕获的异常会成为不稳定的噪声来源,故静默跳过。 pass page.on("response", on_response) # ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ---- page.goto(BASE_URL, wait_until="networkidle") if "/login" not in page.url: fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}") print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}") # ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)---- image = page.locator(".captcha__image img") image.wait_for(state="visible", timeout=10_000) src = image.get_attribute("src") or "" if not src.startswith("data:image/png;base64,"): fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}") if len(src) < 1000: fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染") print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)") # ---- 3. 输错验证码 → 应提示且验证码自动刷新 ---- first_id = captured_captcha_ids[-1] page.fill('input[autocomplete="username"]', USERNAME) page.fill('input[autocomplete="current-password"]', PASSWORD) page.fill('input[autocomplete="off"]', "XXXX") page.click("button:has-text('登 录')") page.wait_for_timeout(1500) if "/login" not in page.url: fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}") second_id = captured_captcha_ids[-1] if second_id == first_id: fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」") print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)") # ---- 4. 正确登录 ---- answer = redis_get(f"ys:captcha:{second_id}") if not answer: fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}") page.fill('input[autocomplete="off"]', answer) page.click("button:has-text('登 录')") page.wait_for_timeout(2000) if current_path(page) != "/dashboard": detail = f"仍停留在 {page.url}" if message := toast_text(page): detail += f",提示:{message}" if console_errors: detail += f",控制台错误:{console_errors[:3]}" fail("登录", detail) page.wait_for_load_state("networkidle") print(f"✓ 4. 登录成功 跳转到 {page.url}") if USERNAME not in page.content(): fail("用户信息", "首页未显示当前用户名") print("✓ 5. 用户信息 首页已展示当前账号") # ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ---- page.reload(wait_until="networkidle") if current_path(page) != "/dashboard": fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}") print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)") # ---- 7. 登出 → 令牌立即失效 ---- page.click("text=退出登录") page.wait_for_url("**/login**", timeout=10_000) print(f"✓ 7. 登出 回到 {page.url}") page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle") page.wait_for_timeout(1000) if current_path(page) == "/dashboard": fail("登出失效", "登出后仍能访问受保护页面") print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页") 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}") 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}") print(" 已保存截图 e2e/failure.png") return 1 finally: browser.close() print("\n全部通过。") return 0 if __name__ == "__main__": raise SystemExit(main())