89 lines
3.9 KiB
Python
89 lines
3.9 KiB
Python
"""
|
||||
|
|
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}"
|
|||
|
|
)
|