- request.ts blob 失败分支:先解析真实文案再统一提示——此前先弹兜底 「导出失败」再异步补弹真实 message(双提示),401 时跳转提示与 实际原因错位;401 语义文案在导出场景不再失真 - useConfirmAction:收尾(列表刷新)成功后才弹成功提示——此前先弹 「已删除」再刷新,after 失败被吞时成功/失败提示同屏矛盾 - 日志页登录 tab 首次成功加载后不再重复拉取(loginLoadedOk 由 fetcher 成功返回置位,失败切回可重拉,不复活「失败停空表」旧坑) - login_flow.py 密码改从 E2E_ADMIN_PASSWORD 读(与 system_management 同一约定),硬编码默认值在他机上会假绿为「凭据错误」 - e2e 冒烟:登录 8 项 + 系统管理 13 项全过(拦截器全局改动验证) Co-Authored-By: Claude Code <noreply@anthropic.com>
159 lines
6.2 KiB
Python
159 lines
6.2 KiB
Python
"""浏览器端到端验证:登录 + 图形验证码。
|
||
|
||
验证的是**只有真实浏览器才能暴露**的问题:
|
||
|
||
- 路由守卫是否真的拦住了未登录访问
|
||
- 验证码图片能否经 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", "Admin@2026")
|
||
|
||
|
||
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())
|