Files
danyang-match/tests/e2e/http_semantics_test.py
WorkBuddy 825fd41ee4 fix(auth): 修复刷新令牌链路三层断点,并把 121 处裸异常收敛为携带 HTTP 语义的异常
用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」这条路整条是断的,
且断在三个互不相干的地方 —— 每修好一层,下一层才显形:

1) 前端漏传 userId → /auth/refresh 直接 400(上一笔 63c18d8 已修)
2) 后端把 refresh token 交给 validateToken 校验,而那是用 access 密钥验签的,
   refresh token 由 refresh 密钥签发 → 必然失败。这是本次的核心修复。
3) 失败被抛成裸 RuntimeException → HTTP 500,客户端当作服务端故障重试。
4) 换发出来的新 refresh token 与旧 token **逐字节相同** —— JWT 的 iat 只有秒级精度、
   且未设 jti,同一用户同一秒内签发的令牌完全一致,「刷新即轮换、旧令牌作废」
   这条安全属性形同不存在(Redis 比对旧值自然相等)。三处签发补 UUID jti。

第 2 层此前被两层掩盖:前端漏传 userId 使请求先 400 走不到验签;且全仓
没有任何 /auth/refresh 用例。第 4 层则只在「同一秒内签发两次」时才暴露,
是新增 E2E 用例(换发后立刻用旧令牌再刷一次)把它逼出来的。

同时修复审计中发现的同族问题:
- AnalyticsService 用的是 shared.exception.BusinessException,而全局处理器只映射
  嵌套类 GlobalExceptionHandler.BusinessException —— 该异常无任何 handler,直落 500。
  现让旧类继承被映射的类,从结构上杜绝「未映射的业务异常静默 500」。
- 小程序 authStore 读 result.needPhone,后端返回 isNewUser → `!undefined === true`,
  每个用户一登录就被标记成已绑手机号。同族的第三个字段名不一致缺陷。
- 小程序 api.js 的 401 分支会无条件触发 onSessionExpired→refresh;后端把刷新失败
  语义修正为 401 后,这条路径会自我递归。补 /auth/* 守卫。
- 登录页只认 instanceof Error,把服务端文案(如「账号已被封禁」)丢了。

后端异常收敛(121 处 → 0):
- 业务拒绝改抛 BusinessException + ApiResponseCode(404 不存在 / 403 无权与状态不允许 /
  409 冲突重复 / 400 参数非法),文案一字未改,避免测试与前端文案契约漂移
- 真正的服务端故障(加解密、文件头、上传、签名、实名 Provider)改为显式
  ServerErrorException:仍是 500,但意图明确、ERROR 记全栈、不向客户端泄露内部细节
- 用带显式映射表的脚本执行(fail-closed,遇未知文案即中止),不用启发式规则

守卫(先有判别力再谈全绿):
- 新增 JwtTokenServiceRefreshTokenTest:钉住「refresh 用 refresh 密钥验签」与
  「两类令牌不得互相顶替」(含两把密钥被配成同值时的 type 声明兜底),
  以及「同一秒内签发的两个令牌必须逐字节不同」
- 新增 RuntimeExceptionThrowGuardTest:扫源码断言 main 零裸抛(无白名单副本)
- GlobalExceptionHandlerTest 补三条:别名的实例必须按其业务码回 403 而非 500、
  无法识别的字符串码 fail-closed 到 500(不得退化成无信息的 400)、
  以及继承关系守卫(把别名类改回 RuntimeException 会静默恢复该缺陷,只有它能咬住)
- 新增 tests/e2e/auth_refresh_test.py:唯一覆盖 /auth/refresh 的用例,
  断言换发的 accessToken 真能过鉴权、旧令牌轮换失效、无效凭据 401 而非 500
- http_semantics_test.py 补「Service 层拒绝必须是 4xx 而非 5xx」
- miniapp-auth-contracts.mjs 补 isNewUser 映射(双向)与 /auth/* 401 不递归

验证:
- 定向后端测试 9/9
- 全量后端测试 113 个测试类、0 失败、BUILD SUCCESS
- 本机真实栈 E2E 72 passed(含 7 个新增刷新用例)
- miniapp tsc 0 错误、UI 契约门控 18/18
- 负向注入两处精准变红(RuntimeExceptionThrowGuardTest 与
  JwtTokenServiceRefreshTokenTest 各 1 例),BUILD FAILURE,
  注入前后工作区 SHA256 逐字节一致(零污染)
2026-09-22 21:41:30 +08:00

171 lines
8.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}"