fix(api): give failures real HTTP semantics; fix 401/403 confusion and CORS default
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.
This commit is contained in:
@@ -128,7 +128,12 @@ def admin_token(api: ApiClient, operator_password: str) -> str:
|
||||
def operator_token(
|
||||
api: ApiClient, operator_password: str
|
||||
) -> Callable[[str], str]:
|
||||
"""按用户名取运营 token(会话内缓存)。用法:`operator_token("counselor01")`。"""
|
||||
"""按用户名取运营 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:
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
HTTP 语义契约 E2E。
|
||||
|
||||
背景:此前 Controller 里约 100 处 `return ApiResponse.error(...)` 不抛异常、
|
||||
不带 @ResponseStatus,HTTP 状态恒为 200 —— 客户端只能靠 body 的 success 判断
|
||||
成败。对内部调用尚可(虽然小程序 api.js 早已写好 401/403/429 分支却永远走不到),
|
||||
对第三方集成是硬伤:集成商不会去比对字符串业务码。
|
||||
|
||||
本文件把「失败必须携带真实 HTTP 语义」钉死在活栈上。
|
||||
单元测试(ApiResponseCodeHttpStatusTest)只能证明枚举映射写对了,
|
||||
证明不了 Controller 直返路径真的用上了它 —— 那要靠这里的真实 HTTP。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
# 一个确定不存在的 UUID:走的是 Controller 里 `return ApiResponse.notFound()` 的直返路径。
|
||||
MISSING_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
def test_controller_direct_return_carries_http_status(api, user_token):
|
||||
"""
|
||||
关键用例:证明 ResponseBodyAdvice 真的覆盖了 Controller 直返。
|
||||
|
||||
这条路径不抛异常、不进 GlobalExceptionHandler,
|
||||
若 Advice 失效(例如包扫描漏了、或 supports 判断写错),
|
||||
它会悄悄退回 200 —— 而枚举层面的单元测试依然全绿。
|
||||
"""
|
||||
resp = api.call(
|
||||
"POST", f"/matches/{MISSING_UUID}/success/request", token=user_token, json={}
|
||||
)
|
||||
assert resp.status_code == 404, (
|
||||
f"Controller 直返 notFound() 应为 HTTP 404,实际 {resp.status_code}。"
|
||||
f"若为 200,说明 ApiResponseStatusAdvice 未生效:{resp.text[:300]}"
|
||||
)
|
||||
|
||||
|
||||
def test_unauthenticated_is_401_not_403(api):
|
||||
"""
|
||||
未认证必须是 401,不能是 403。
|
||||
|
||||
Spring Security 未配 authenticationEntryPoint 时默认走
|
||||
Http403ForbiddenEntryPoint,未认证也回 403 —— 调用方无法区分
|
||||
「没带 token」和「带了但权限不够」,小程序也不会触发重新登录。
|
||||
"""
|
||||
resp = api.call("GET", "/notifications")
|
||||
assert resp.status_code == 401, (
|
||||
f"未认证应回 401,实际 {resp.status_code}:{resp.text[:300]}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("code") == "UNAUTHORIZED", f"body 业务码应为 UNAUTHORIZED:{body}"
|
||||
|
||||
|
||||
def test_error_responses_share_uniform_body(api, user_token):
|
||||
"""
|
||||
无论 4xx 来自哪条路径(Controller 直返 / 异常处理器 / Security 入口点),
|
||||
body 结构必须一致,否则第三方要写三套解析逻辑。
|
||||
"""
|
||||
cases = [
|
||||
("GET", "/notifications", None), # 401,Security 入口点
|
||||
("GET", "/activities/types/not-a-real-type", user_token), # 400,异常处理器
|
||||
("POST", f"/matches/{MISSING_UUID}/success/request", user_token), # 404,Controller 直返
|
||||
]
|
||||
for method, path, token in cases:
|
||||
resp = api.call(method, path, token=token, json={} if method == "POST" else None)
|
||||
assert 400 <= resp.status_code < 500, (
|
||||
f"{method} {path} 应回 4xx,实际 {resp.status_code}:{resp.text[:300]}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("success") is False, f"{path} 的 success 应为 false:{body}"
|
||||
assert body.get("code"), f"{path} 缺少业务码 code:{body}"
|
||||
assert body.get("message"), f"{path} 缺少提示 message:{body}"
|
||||
|
||||
|
||||
def test_not_found_is_used_to_avoid_existence_probing(api, user_token):
|
||||
"""
|
||||
访问不属于自己的 Match 必须回 404 而非 403。
|
||||
|
||||
这是刻意的安全设计(见 MatchController#authorizedMatch 注释):
|
||||
若回 403,攻击者就能枚举 ID 区分「存在但无权」与「根本不存在」,
|
||||
从而探测他人关系是否存在。
|
||||
"""
|
||||
resp = api.call(
|
||||
"POST", f"/matches/{MISSING_UUID}/success/confirm", token=user_token, json={}
|
||||
)
|
||||
assert resp.status_code == 404, (
|
||||
f"非本人 Match 应回 404(避免存在性探测),实际 {resp.status_code}"
|
||||
)
|
||||
@@ -32,14 +32,18 @@ def test_activities_can_be_filtered_by_type(api, user_token, activity_type):
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_activity_type_returns_bad_request_not_500(api, user_token):
|
||||
def test_invalid_activity_type_returns_400(api, user_token):
|
||||
"""
|
||||
非法类型属客户端参数错误,必须回 BAD_REQUEST。
|
||||
非法类型属客户端参数错误,必须回 HTTP 400。
|
||||
|
||||
注意本项目的约定:业务错误是 **HTTP 200 + body.code**(只有框架层
|
||||
鉴权失败才是真 HTTP 403)。因此这里断言的是 code,不是 HTTP 状态码。
|
||||
这里曾断言「HTTP 200 + body.code == BAD_REQUEST」——那是迁就了当时的实现:
|
||||
handleIllegalArgument 补映射时漏了 @ResponseStatus,于是返回 200。
|
||||
约定已检修(业务码自带 HTTP 语义),断言随之回到正确的期望值。
|
||||
"""
|
||||
resp = api.call("GET", "/activities/types/not-a-real-type", token=user_token)
|
||||
assert resp.status_code != 500, f"非法活动类型触发了 500:{resp.text[:200]}"
|
||||
assert resp.status_code == 400, (
|
||||
f"非法活动类型应回 HTTP 400,实际 {resp.status_code}:{resp.text[:200]}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("code") == "BAD_REQUEST", f"非法类型应回 BAD_REQUEST,实际 {body}"
|
||||
assert body.get("code") == "BAD_REQUEST", f"body 业务码应为 BAD_REQUEST,实际 {body}"
|
||||
assert body.get("success") is False
|
||||
|
||||
Reference in New Issue
Block a user