Files
danyang-match/tests/e2e/http_semantics_test.py
T

171 lines
8.0 KiB
Python
Raw Normal View History

"""
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}"
)
def test_operator_identity_cannot_reach_user_endpoints(api, admin_token):
"""
运营身份访问用户端端点必须 403,且请求不得进入 Controller。
原因:`@AuthenticationPrincipal UserPrincipal` 遇到 OperatorPrincipal 属类型不匹配,
Spring Security 默认返回 null(errorOnInvalidType=false)。全仓 244 处使用该注解、
仅 48 处做了 null 检查,其余端点会 NPE → 500。
与其补上百处 null 判断,不如在认证层按身份挡住。
若这里返回 500(而非 403),说明拦截规则失效、请求又进到了 Controller。
"""
for path in ("/notifications", "/me"):
resp = api.call("GET", path, token=admin_token)
assert resp.status_code == 403, (
f"运营 token 访问用户端 {path} 应回 403,实际 {resp.status_code}:{resp.text[:300]}"
)
body = resp.json()
assert body.get("code") == "FORBIDDEN", f"{path} 业务码应为 FORBIDDEN:{body}"
def test_user_identity_cannot_reach_operator_endpoints(api, user_token):
"""反向:用户身份打运营端点同样 403(不得因改动而放开)。"""
resp = api.call("GET", "/operator/banners", token=user_token)
assert resp.status_code == 403, (
f"用户 token 访问运营端应回 403,实际 {resp.status_code}:{resp.text[:300]}"
)
def test_nested_operator_endpoints_still_require_operator_role(api, user_token):
"""
嵌套在业务路径下的运营端点也必须挡住。
`/counseling/services/operator/queue`、`/content/operator/articles`、
`/activities/operator/checkin/scan` 这类端点根路径不是 /operator/,
若拦截规则只写 `/operator/**` 就会漏掉它们。
"""
for path in (
"/counseling/services/operator/queue",
"/content/operator/articles",
"/service-appointments/operator/list",
):
resp = api.call("GET", path, token=user_token)
assert resp.status_code == 403, (
f"用户 token 访问嵌套运营端点 {path} 应回 403,实际 {resp.status_code}"
)
# 业务拒绝(Service 层主动 throw)必须回 4xx 而不是 500。
#
# 2026-09-22 审计:全仓 121 处裸 `throw new RuntimeException(...)` 经兜底处理器
# 一律变成 500。这比「状态码不精确」严重得多:
# * 客户端把「活动不存在」读成「服务端故障」,于是重试,而重试必然再失败;
# * 监控 / APM 的 5xx 指标被业务拒绝污染,真实故障淹没在噪声里;
# * 网关可能对 5xx 触发熔断,一个用户传错 ID 就能影响其他用户。
# 收敛后由 ApiResponseCode 自带 HTTP 语义(400 改请求 / 403 别重试 / 404 不存在 /
# 409 先查状态)。这里在活栈上钉住这条链路,单元测试证明不了 Service 抛出的异常
# 真的走到了正确的处理器。
def test_service_thrown_rejections_are_4xx_not_5xx(api, new_user):
"""Service 层主动拒绝 → 4xx,且带业务码(用于区分「谁的问题」)。"""
user = new_user()
cases = [
# 活动不存在 → NOT_FOUND(此前裸抛 → 500)
("POST", f"/activities/{MISSING_UUID}/register", user["token"],
{"emergencyContactName": "张三", "emergencyContactPhone": "13800138000"},
(404,)),
# 举报自己 → BAD_REQUEST(此前裸抛 → 500)
("POST", "/risk/reports", user["token"],
{"reportedUserId": user["user_id"], "reasonType": "fraud", "description": "自举报"},
(400,)),
]
for method, path, token, payload, expected in cases:
resp = api.call(method, path, token=token, json=payload)
assert resp.status_code in expected, (
f"{method} {path} 应回 {expected},实际 {resp.status_code}。"
f"若为 500,说明该拒绝仍是裸 RuntimeException 或未映射的异常类型:"
f"{resp.text[:300]}"
)
body = resp.json()
assert body.get("success") is False, f"{path} 的 success 应为 false:{body}"
assert body.get("code"), f"{path} 必须带业务码,否则客户端无法分辨原因:{body}"