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:
WorkBuddy
2026-09-18 14:34:58 +08:00
parent 730dddde30
commit 7682abd85c
13 changed files with 767 additions and 99 deletions
+88
View File
@@ -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}"
)