Continues the HTTP-semantics work. Fixing 401/403 exposed a worse defect on the
same axis: who is allowed to reach which endpoint.
Root cause chain:
- PlatformPrincipal has two implementations (OperatorPrincipal / UserPrincipal),
chosen by JwtAuthFilter from the token type.
- `@AuthenticationPrincipal UserPrincipal` against an OperatorPrincipal is a type
mismatch, and Spring Security returns null (errorOnInvalidType defaults to
false) instead of failing.
- 244 uses of that annotation repo-wide; only 48 null-check. The rest NPE -> 500
when reached with an operator identity.
- The fallback rule was `.authenticated()`, which an operator token satisfies, so
such requests did reach user-facing controllers.
Adding ~196 null checks would be mechanical, easy to miss, and does nothing to
constrain future code. The mismatch is between identity type and endpoint, so it
is fixed once, at the authentication layer: the fallback becomes
`.hasRole("USER")` (operator -> ROLE_OPERATOR, user -> ROLE_USER, per JwtAuthFilter).
Operator requests to user endpoints now get 403 before any controller runs.
Trap worth recording: operator endpoints are not all under /operator/ —
/counseling/services/operator/queue, /content/operator/articles,
/activities/operator/checkin/scan, /service-appointments/operator/list and others
sit deeper inside business paths. The first attempt covered them with
`requestMatchers("/**/operator/**")`, which fails at runtime:
PatternParseException: No more pattern data allowed after {*...} or ** pattern element
Spring's PathPattern permits `**` only as the final element. Worse, the symptom is
a 500 from the whole filter chain (Tomcat HTML error page, not our JSON), and E2E
went from 37 passed to 33 failed — an error far removed from its cause. Now the
nine prefixes are listed explicitly, each ending in /**.
Because explicit enumeration can be forgotten as endpoints are added, and because
the failure mode is "operators locked out of their own admin module" (invisible to
user-side tests), SecurityPathCoverageTest parses the OPERATOR rule prefixes out of
SecurityConfig source, walks every *Controller.java, joins class-level and
method-level mapping paths, and asserts every path containing /operator/ is
covered (/auth/operator/** excluded by design). Two things that would silently
neuter it, both handled: it must parse only the .hasRole("OPERATOR") chain — pulling
in the permitAll or fallback rules would mark everything covered — and the test
working directory is the repo root, not the Maven module root.
Verification: negatively validated by removing /content/operator/** -> exactly one
test turns red listing the six affected ContentController endpoints; restored.
E2E 40 passed (37 + 3 identity-isolation cases). Backend regression 664 / 0
failures / 0 errors / 3 skipped.
136 lines
5.9 KiB
Python
136 lines
5.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}"
|
||
)
|
||
|
||
|
||
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}"
|
||
)
|