Phase 23B ended with an E2E assertion changed to expect "HTTP 200 + body.code == BAD_REQUEST", documented at the time as "this project's convention". That description was accurate about the code and wrong about the intent: it described a defect, and I made the test accommodate it instead of fixing it. Auditing it found the convention was already broken, not merely risky: - ~100 sites (105, across 30 controllers) do `return ApiResponse.error(...)` / `forbidden()` / `notFound()` with no exception and no `@ResponseStatus`, so the HTTP status is always 200. Gateways and APM cannot separate 4xx from 5xx; callers cannot tell "fix your request" (400) from "don't retry" (403) from "check state first" (409) from "retry later" (429). - miniapp/utils/api.js already had 401/403/429 branches that could never be reached, since the backend never returned those codes. The concrete damage: an expired token returned 403, so `onSessionExpired` never fired and users were stuck with no way back to the login flow. - SecurityConfig had no `authenticationEntryPoint`, so Spring's default `Http403ForbiddenEntryPoint` returned 403 for unauthenticated calls, with a framework body that has no `code` field at all. - CorsConfig defaulted `allowed-origins` to `*` together with `allowCredentials(true)`, while its comment claimed "localhost only by default". Comment contradicting code is worse than no comment: it reads as safe. - Only 4 files / 15 assertions in the whole backend suite touched HTTP status, which is how this survived. Approach: one definition, two applications, zero controller changes. - ApiResponseCode gains an `httpStatus` field — the single source of truth. The constructor requires it, so a new code cannot be added without one. - GlobalExceptionHandler reads status from the enum instead of hardcoding `@ResponseStatus` per handler. - New ApiResponseStatusAdvice (ResponseBodyAdvice) fills in the status just before the body is written, covering all ~100 direct-return sites without touching them. Two constraints, both noted in code: only downgrade when the current status is still 2xx (the advice also sees exception-handler results, and must not override an already-correct 401/403/409), and never alter the body. - SecurityConfig: entry point returns 401 with a uniform ApiResponse body; access denied handler returns 403 likewise. - CorsConfig: default changed to empty (deny). Both clients are same-origin anyway — nginx proxies /api/ and Vite proxies /api — so nothing depends on it. - miniapp api.js: 401/403/429/409 branches now prefer the server's `body.message`, otherwise "账号已被封禁" would be flattened to "无权限访问". Implementation trap worth remembering: `ServerHttpResponse` has `setStatusCode` but no getter, so reading the current status requires casting to `ServletServerHttpResponse`. Verification: 37 E2E passed (33 + 4 new contract cases), including a live-stack check that a controller's direct `notFound()` now yields 404 — that path throws no exception and never reaches the exception handler, so if the advice were inactive it would silently return 200 while the enum unit tests stayed green. Backend regression 662 / 0 failures / 0 errors / 3 skipped (+6 new guards). The first regression run had 3 failures, all tests asserting the old wrong behavior (anonymous access expected 403; outsiders reading a conversation expected 200). Fixed the assertions, not the implementation. Where 404 is used instead of 403 for "invisible", that is deliberate — it prevents probing whether another user's conversation exists — and the substantive assertions (e.g. that an unauthorized response must not leak message bodies) were kept. Not done, needs a product decision: third-party credential issuance, whether rate limiting is actually active, publishing OpenAPI externally, per-caller audit and quotas, pagination/idempotency coverage, desensitization boundaries.
178 lines
5.9 KiB
Python
178 lines
5.9 KiB
Python
"""
|
||
善缘丹阳 E2E 测试基建(pytest conftest)。
|
||
|
||
设计纪律(Phase 23 复验后确立,不得回退):
|
||
|
||
1. **栈不可达 = 退出,不是跳过。** `skip` 会把"未执行"伪装成"通过",
|
||
这正是 Phase 23 清掉的 44 个 `pass` 空壳的另一种变体。因此本文件在
|
||
活栈不可达时调用 `pytest.exit(returncode=4)` —— 明确报"没跑成"。
|
||
2. **不 mock 业务行为。** 所有 fixture 走真实 HTTP 与真实数据库;
|
||
唯一被替换的是微信登录(后端 `MockWechatLoginProvider` 默认激活)。
|
||
3. **用户隔离。** 每个用例拿独立新用户(mock 登录同 code 同 openid,
|
||
换 code 即换人),避免用例互相污染与"上一个用例造的数据让本用例变绿"。
|
||
|
||
环境变量:
|
||
- `E2E_BASE_URL` 活栈地址,默认 http://localhost:8085/api/v1
|
||
- `E2E_OPERATOR_PASSWORD` 运营账号密码(必需;CI 由生成的 .env 提供)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
import uuid
|
||
from typing import Any, Callable, Dict, Optional
|
||
|
||
import pytest
|
||
import requests
|
||
|
||
DEFAULT_BASE_URL = "http://localhost:8085/api/v1"
|
||
HEALTH_PATH = "/health/live"
|
||
|
||
|
||
class ApiClient:
|
||
"""极薄 HTTP 客户端:只负责超时与错误上下文,不做业务判断。"""
|
||
|
||
def __init__(self, base_url: str, timeout: float = 15.0) -> None:
|
||
self.base_url = base_url.rstrip("/")
|
||
self.timeout = timeout
|
||
self.session = requests.Session()
|
||
# E2E 打的是 localhost 活栈。若宿主机设置了 http_proxy,
|
||
# requests 默认会把 localhost 请求也发给代理并返回 502,
|
||
# 表现为"连接失败"而非真实的业务结果——直接绕开环境变量。
|
||
self.session.trust_env = False
|
||
|
||
def call(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
token: Optional[str] = None,
|
||
json: Optional[Dict[str, Any]] = None,
|
||
params: Optional[Dict[str, Any]] = None,
|
||
) -> requests.Response:
|
||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||
return self.session.request(
|
||
method,
|
||
f"{self.base_url}{path}",
|
||
headers=headers,
|
||
json=json,
|
||
params=params,
|
||
timeout=self.timeout,
|
||
)
|
||
|
||
def ok(self, method: str, path: str, **kwargs: Any) -> Any:
|
||
"""断言 HTTP 200 且业务 `success=true`,返回 `data`。"""
|
||
resp = self.call(method, path, **kwargs)
|
||
assert resp.status_code == 200, (
|
||
f"{method} {path} -> HTTP {resp.status_code}: {resp.text[:400]}"
|
||
)
|
||
body = resp.json()
|
||
assert body.get("success") is True, f"{method} {path} -> 业务失败: {body}"
|
||
return body.get("data")
|
||
|
||
|
||
# ---------------------------------------------------------------- fixtures
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def base_url() -> str:
|
||
return os.environ.get("E2E_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def api(base_url: str) -> ApiClient:
|
||
return ApiClient(base_url)
|
||
|
||
|
||
@pytest.fixture(scope="session", autouse=True)
|
||
def live_stack(api: ApiClient) -> None:
|
||
"""会话级探活。栈不可达 → 退出(退出码 4),绝不 skip。"""
|
||
last = "unknown"
|
||
for _ in range(3):
|
||
try:
|
||
resp = api.call("GET", HEALTH_PATH)
|
||
if resp.status_code == 200:
|
||
return
|
||
last = f"HTTP {resp.status_code}"
|
||
except requests.RequestException as exc:
|
||
last = repr(exc)
|
||
time.sleep(2)
|
||
pytest.exit(
|
||
f"E2E 需要活栈:GET {api.base_url}{HEALTH_PATH} 不可达({last})。\n"
|
||
f"请先起栈(infrastructure/docker-compose.yml)或设置 E2E_BASE_URL。",
|
||
returncode=4,
|
||
)
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def operator_password() -> str:
|
||
pwd = os.environ.get("E2E_OPERATOR_PASSWORD")
|
||
if not pwd:
|
||
pytest.exit("E2E_OPERATOR_PASSWORD 未设置:E2E 无法登录运营账号,不能执行", returncode=4)
|
||
return pwd
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def admin_token(api: ApiClient, operator_password: str) -> str:
|
||
data = api.ok(
|
||
"POST",
|
||
"/auth/operator/login",
|
||
json={"username": "admin", "password": operator_password},
|
||
)
|
||
token = data.get("accessToken")
|
||
assert token, "admin 登录未返回 accessToken"
|
||
return token
|
||
|
||
|
||
@pytest.fixture
|
||
def operator_token(
|
||
api: ApiClient, operator_password: str
|
||
) -> Callable[[str], str]:
|
||
"""按用户名取运营 token(会话内缓存)。
|
||
|
||
可用账号见 tests/e2e/sql/seed_e2e_operators.sql:
|
||
e2e_admin / e2e_platform / e2e_counselor / e2e_station / e2e_matchmaker / e2e_content,
|
||
外加迁移 seed 的 admin。用法:`operator_token("e2e_counselor")`。
|
||
"""
|
||
cache: Dict[str, str] = {}
|
||
|
||
def _get(username: str) -> str:
|
||
if username not in cache:
|
||
data = api.ok(
|
||
"POST",
|
||
"/auth/operator/login",
|
||
json={"username": username, "password": operator_password},
|
||
)
|
||
token = data.get("accessToken")
|
||
assert token, f"{username} 登录未返回 accessToken"
|
||
cache[username] = token
|
||
return cache[username]
|
||
|
||
return _get
|
||
|
||
|
||
@pytest.fixture
|
||
def new_user(api: ApiClient) -> Callable[[], Dict[str, Any]]:
|
||
"""注册并登录一个全新用户,返回 {'token','user_id','code'}。"""
|
||
created: list = []
|
||
|
||
def _create() -> Dict[str, Any]:
|
||
code = f"e2e-{uuid.uuid4()}"
|
||
data = api.ok("POST", "/auth/wechat/login", json={"code": code})
|
||
user = {
|
||
"token": data.get("accessToken"),
|
||
"user_id": data.get("userId"),
|
||
"code": code,
|
||
}
|
||
assert user["token"] and user["user_id"], f"新用户登录返回异常: {data}"
|
||
created.append(user)
|
||
return user
|
||
|
||
return _create
|
||
|
||
|
||
@pytest.fixture
|
||
def user_token(new_user: Callable[[], Dict[str, Any]]) -> str:
|
||
"""独立新用户的 token。"""
|
||
return new_user()["token"]
|