fix(security): stop operator identity from reaching user endpoints; guard path rules
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.
This commit is contained in:
@@ -71,12 +71,54 @@ public class SecurityConfig {
|
||||
.requestMatchers("/app/config").permitAll()
|
||||
.requestMatchers("/activities").permitAll()
|
||||
.requestMatchers("/auth/**").authenticated()
|
||||
// 操作台接口需要认证
|
||||
.requestMatchers("/operator/**").hasRole("OPERATOR")
|
||||
// 管理接口需要认证
|
||||
.requestMatchers("/admin/**").hasRole("OPERATOR")
|
||||
// 其他所有用户接口需要认证(覆盖 /api/v1/** 中未匹配的)
|
||||
.requestMatchers("/**").authenticated()
|
||||
/**
|
||||
* 运营端接口要求 OPERATOR 角色。
|
||||
*
|
||||
* <p>除了顶层的 {@code /operator/**},还必须逐个列出「嵌在业务路径下」的
|
||||
* 运营端点,例如 {@code /counseling/services/operator/queue}、
|
||||
* {@code /content/operator/articles}、
|
||||
* {@code /activities/operator/checkin/scan}、
|
||||
* {@code /service-appointments/operator/list}。
|
||||
* 漏掉任何一个,该端点都会掉进下面的 ROLE_USER 规则,
|
||||
* 导致运营人员被自己的平台拒绝(管理端对应模块直接不可用)。
|
||||
*
|
||||
* <p><b>不要试图用 {@code /**}{@code /operator/**} 一行覆盖</b>:
|
||||
* Spring 的 PathPattern 语法不允许 {@code **} 出现在中间
|
||||
* (只有末尾才允许),写了会在运行时抛
|
||||
* {@code PatternParseException: No more pattern data allowed after ** pattern element},
|
||||
* 且表现为整个过滤器链 500 —— 已实测踩过。
|
||||
*
|
||||
* <p>这些前缀由 {@code SecurityPathCoverageTest} 机械校验:
|
||||
* 源码中任何含 operator 的端点路径都必须被这里的规则覆盖。
|
||||
*/
|
||||
.requestMatchers(
|
||||
"/operator/**",
|
||||
"/admin/**",
|
||||
"/counseling/services/operator/**",
|
||||
"/content/operator/**",
|
||||
"/risk/operator/**",
|
||||
"/activities/operator/**",
|
||||
"/service-appointments/operator/**",
|
||||
"/service-programs/operator/**",
|
||||
"/referrals/operator/**")
|
||||
.hasRole("OPERATOR")
|
||||
/**
|
||||
* 其余接口必须持用户身份(ROLE_USER),不再只是"已认证"。
|
||||
*
|
||||
* <p>此前这里是 {@code authenticated()}:运营 token 也算"已认证",
|
||||
* 于是能用运营账号去打用户端端点。而
|
||||
* {@code @AuthenticationPrincipal UserPrincipal} 遇到 OperatorPrincipal
|
||||
* 属类型不匹配,Spring Security 默认返回 <b>null</b>
|
||||
* ({@code errorOnInvalidType=false})——
|
||||
* 全仓 244 处使用该注解,仅 48 处做了 null 检查,
|
||||
* 其余端点会直接 NPE → 500。
|
||||
*
|
||||
* <p>与其补上百处 null 判断(机械、易漏、且无法约束将来新增的代码),
|
||||
* 不如在认证层按身份类型挡住:运营身份访问用户端一律 403,
|
||||
* 请求根本进不到 Controller。身份与角色的对应关系见 JwtAuthFilter:
|
||||
* 运营 → ROLE_OPERATOR + 权限;用户 → ROLE_USER。
|
||||
*/
|
||||
.requestMatchers("/**").hasRole("USER")
|
||||
// 其他所有请求放行(避免 denyAll 导致未知路径也被拦截)
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.danyang.match.platform.shared.config;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 机械校验:源码里每个「运营端点」都必须被 SecurityConfig 的 OPERATOR 规则覆盖。
|
||||
*
|
||||
* <p>为什么需要:运营端点并不都在 {@code /operator/} 下。部分业务控制器把运营端点
|
||||
* 嵌在深层路径里({@code /counseling/services/operator/queue}、
|
||||
* {@code /content/operator/articles}、{@code /service-appointments/operator/list} …)。
|
||||
* 一旦某个这类端点没被显式纳入 OPERATOR 规则,它会掉进兜底的 ROLE_USER 规则,
|
||||
* 结果是<b>运营人员被自己的平台拒绝</b>——管理端对应模块直接不可用,
|
||||
* 而这在只跑用户侧测试时完全看不出来。
|
||||
*
|
||||
* <p>不能靠 {@code /**}{@code /operator/**} 一行通配解决:PathPattern 不允许
|
||||
* {@code **} 出现在中间,写了会抛 PatternParseException 并把整个过滤器链打成 500。
|
||||
* 所以只能显式枚举 —— 那就必须有人盯着别漏,这个测试就是那个人。
|
||||
*/
|
||||
class SecurityPathCoverageTest {
|
||||
|
||||
private static final Path SRC_ROOT = resolveSourceRoot();
|
||||
private static final Path SECURITY_CONFIG = SRC_ROOT.resolve(
|
||||
"com/danyang/match/platform/shared/config/SecurityConfig.java");
|
||||
|
||||
/**
|
||||
* 定位源码根目录。
|
||||
*
|
||||
* <p>测试的工作目录在本环境下是<b>仓库根</b>而非 Maven 模块根
|
||||
* (同项目 `MigrationValidationTest` 也以 `backend/target/...` 形式引用产物),
|
||||
* 但不同执行方式下可能不同。两种候选都试一遍,比写死一个更稳。
|
||||
*/
|
||||
private static Path resolveSourceRoot() {
|
||||
for (String candidate : new String[]{"src/main/java", "backend/src/main/java"}) {
|
||||
Path path = Path.of(candidate);
|
||||
if (Files.isDirectory(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"无法定位源码根目录(尝试过 src/main/java 与 backend/src/main/java);当前工作目录="
|
||||
+ Path.of("").toAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("源码中所有运营端点路径都被 SecurityConfig 的 OPERATOR 规则覆盖")
|
||||
void everyOperatorEndpointIsGuardedByOperatorRule() throws IOException {
|
||||
Set<String> prefixes = operatorRulePrefixes();
|
||||
// 健全性检查:确认解析器真的取到了 OPERATOR 规则(而非 permitAll 之类)。
|
||||
// 若这条挂了,说明下面是"零覆盖也算全通过"的假绿,必须先修解析逻辑。
|
||||
assertTrue(prefixes.contains("/operator/**"),
|
||||
"未能从 SecurityConfig 解析出运营规则前缀,测试本身已失效。实际解析结果:" + prefixes);
|
||||
|
||||
List<String> unguarded = new ArrayList<>();
|
||||
try (Stream<Path> files = Files.walk(SRC_ROOT)) {
|
||||
for (Path file : files.filter(p -> p.toString().endsWith("Controller.java")).toList()) {
|
||||
String src = Files.readString(file);
|
||||
String classPath = firstMappingPath(src);
|
||||
for (String methodPath : methodMappingPaths(src)) {
|
||||
String full = join(classPath, methodPath);
|
||||
if (isOperatorPath(full) && !isCovered(full, prefixes)) {
|
||||
unguarded.add(file.getFileName() + " -> " + full);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(unguarded.isEmpty(),
|
||||
"以下运营端点未被 SecurityConfig 的 OPERATOR 规则覆盖,"
|
||||
+ "运营账号访问会被 403(管理端对应功能不可用)。"
|
||||
+ "请在 SecurityConfig 的 OPERATOR requestMatchers 中补上其前缀:\n"
|
||||
+ String.join("\n", unguarded));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("兜底规则必须是 ROLE_USER,不能退回 authenticated()")
|
||||
void fallbackRuleRequiresUserRole() throws IOException {
|
||||
String cfg = Files.readString(SECURITY_CONFIG);
|
||||
/*
|
||||
* 若兜底退回 authenticated(),运营 token 也算"已认证",
|
||||
* 于是能打进用户端端点;而 @AuthenticationPrincipal UserPrincipal
|
||||
* 遇 OperatorPrincipal 会返回 null → 未做 null 检查的端点 NPE → 500。
|
||||
* 全仓 244 处使用该注解、仅 48 处做了检查 —— 所以这条兜底不能松。
|
||||
*/
|
||||
assertTrue(cfg.contains("hasRole(\"USER\")"),
|
||||
"兜底规则必须是 hasRole(\"USER\");退回 authenticated() 会让运营身份打进用户端并触发 NPE → 500");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/**
|
||||
* 从 SecurityConfig 源码解析出 **OPERATOR 规则**的路径前缀(去掉结尾的 **)。
|
||||
*
|
||||
* <p>只取与 {@code .hasRole("OPERATOR")} 同一条链的 {@code requestMatchers}。
|
||||
* 这一点必须严格:若把 permitAll 或兜底的 {@code /**} 也收进来,
|
||||
* 任何路径都会被判为"已覆盖",本测试就变成永远通过的摆设。
|
||||
*/
|
||||
private static Set<String> operatorRulePrefixes() throws IOException {
|
||||
String cfg = Files.readString(SECURITY_CONFIG);
|
||||
Set<String> prefixes = new LinkedHashSet<>();
|
||||
Matcher blocks = Pattern.compile(
|
||||
"requestMatchers\\(([^)]*)\\)\\s*\\.hasRole\\(\"OPERATOR\"\\)", Pattern.DOTALL).matcher(cfg);
|
||||
while (blocks.find()) {
|
||||
Matcher literals = Pattern.compile("\"([^\"]+)\"").matcher(blocks.group(1));
|
||||
while (literals.find()) {
|
||||
prefixes.add(literals.group(1));
|
||||
}
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
private static String firstMappingPath(String src) {
|
||||
Matcher m = Pattern.compile("@RequestMapping\\(\\s*\"([^\"]*)\"").matcher(src);
|
||||
return m.find() ? m.group(1) : "";
|
||||
}
|
||||
|
||||
private static List<String> methodMappingPaths(String src) {
|
||||
List<String> paths = new ArrayList<>();
|
||||
Matcher m = Pattern.compile("@(?:Get|Post|Put|Patch|Delete)Mapping\\(\\s*\"([^\"]*)\"")
|
||||
.matcher(src);
|
||||
while (m.find()) {
|
||||
paths.add(m.group(1));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static String join(String classPath, String methodPath) {
|
||||
String base = classPath == null ? "" : classPath;
|
||||
String tail = methodPath == null ? "" : methodPath;
|
||||
String joined = (base + tail).replaceAll("/{2,}", "/");
|
||||
return joined.isEmpty() ? "/" : joined;
|
||||
}
|
||||
|
||||
private static boolean isOperatorPath(String path) {
|
||||
// /auth/operator/** 是运营账号的登录/刷新端点,按设计属于公开或已认证接口,
|
||||
// 由更靠前的 /auth 规则管辖,不应要求 OPERATOR 角色(否则登录本身就进不去)。
|
||||
if (path.startsWith("/auth/")) {
|
||||
return false;
|
||||
}
|
||||
return path.contains("/operator/") || path.startsWith("/operator") || path.equals("/operator");
|
||||
}
|
||||
|
||||
private static boolean isCovered(String fullPath, Set<String> prefixes) {
|
||||
for (String prefix : prefixes) {
|
||||
if (prefix.endsWith("/**")) {
|
||||
String base = prefix.substring(0, prefix.length() - 3);
|
||||
if (fullPath.equals(base) || fullPath.startsWith(base + "/")) {
|
||||
return true;
|
||||
}
|
||||
} else if (fullPath.equals(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -139,11 +139,11 @@ config.setAllowCredentials(true); // 允许携带凭证
|
||||
### 3. 后端全量回归
|
||||
|
||||
```
|
||||
Tests run: 662, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
|
||||
Tests run: 664, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
|
||||
```
|
||||
|
||||
基线 656(Phase 23B 后),净 +6 为新增的
|
||||
`ApiResponseCodeHttpStatusTest`(6 条映射语义守卫)。
|
||||
基线 656(Phase 23B 后),净 +8:`ApiResponseCodeHttpStatusTest`(6 条映射语义守卫)
|
||||
+ `SecurityPathCoverageTest`(2 条路径覆盖守卫,见第七节)。
|
||||
3 个 skip 均有显式原因,无一为空壳。
|
||||
|
||||
首个回归轮次为 **662 / 3 failures**,三处失败均已判定为「测试断言了旧的错误行为」
|
||||
@@ -185,13 +185,103 @@ Tests run: 662, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
|
||||
|
||||
---
|
||||
|
||||
## 七、结论
|
||||
## 七、补充:身份类型与路径规则的收敛(同日续做)
|
||||
|
||||
修完 401/403 之后,顺着同一条线索继续查,发现一个**与错误响应质量直接相关**的更严重缺陷。
|
||||
|
||||
### 1. 根因链条
|
||||
|
||||
`PlatformPrincipal` 有两个实现:`OperatorPrincipal`(运营 token)与 `UserPrincipal`(用户 token),
|
||||
由 `JwtAuthFilter` 按 token 类型选择。
|
||||
|
||||
而 `@AuthenticationPrincipal UserPrincipal` 遇到 `OperatorPrincipal` 时属**类型不匹配**,
|
||||
Spring Security 默认返回 **null**(`errorOnInvalidType=false`)。
|
||||
|
||||
后果:全仓 **244 处**使用该注解,**仅 48 处**做了 null 检查。
|
||||
其余端点在运营身份访问时会直接 NPE → **500**。
|
||||
|
||||
这不是理论风险——`SecurityConfig` 当时的兜底规则是 `authenticated()`,
|
||||
而运营 token 完全满足"已认证",请求可以一路打进用户端 Controller。
|
||||
|
||||
### 2. 为什么不补 null 检查
|
||||
|
||||
补上百处 `if (principal == null)` 是机械劳动,还会遗漏、也无法约束将来新增的代码。
|
||||
根因是**身份类型与端点不匹配**,应该在认证层解决,而不是在每个端点重复防御。
|
||||
|
||||
### 3. 修法:兜底规则从「已认证」收紧为「用户身份」
|
||||
|
||||
```java
|
||||
.requestMatchers("/**").hasRole("USER") // 原为 .authenticated()
|
||||
```
|
||||
|
||||
运营身份访问用户端 → 403,请求根本进不到 Controller,196 处潜在 NPE 一次性消解。
|
||||
角色对应关系由 `JwtAuthFilter` 保证:运营 → `ROLE_OPERATOR`,用户 → `ROLE_USER`。
|
||||
|
||||
### 4. ⚠️ 踩坑:`/**/operator/**` 是非法模式
|
||||
|
||||
运营端点并非都在 `/operator/` 下——`/counseling/services/operator/queue`、
|
||||
`/content/operator/articles`、`/activities/operator/checkin/scan`、
|
||||
`/service-appointments/operator/list` 等嵌在业务路径深处。
|
||||
|
||||
第一版试图用 `/**/operator/**` 一行通配,结果:
|
||||
|
||||
```
|
||||
PatternParseException: No more pattern data allowed after {*...} or ** pattern element
|
||||
```
|
||||
|
||||
**Spring 的 PathPattern 不允许 `**` 出现在中间**,只允许在末尾。
|
||||
更糟的是它的表现:整个过滤器链直接 **500**(Tomcat HTML 错误页,不是我们的 JSON),
|
||||
E2E 从 37 passed 变成 **33 failed**。
|
||||
|
||||
改为显式枚举 9 个前缀(全部符合 PathPattern 语法):
|
||||
|
||||
```
|
||||
/operator/** /admin/**
|
||||
/counseling/services/operator/** /content/operator/** /risk/operator/**
|
||||
/activities/operator/** /service-appointments/operator/**
|
||||
/service-programs/operator/** /referrals/operator/**
|
||||
```
|
||||
|
||||
### 5. 机械守卫:`SecurityPathCoverageTest`
|
||||
|
||||
显式枚举的代价是"将来新增会漏",而漏掉的表现是
|
||||
**运营人员被自己的平台拒绝(管理端对应模块不可用)**——只跑用户侧测试根本看不出来。
|
||||
|
||||
因此新增守卫,**从 `SecurityConfig` 源码解析出 OPERATOR 规则前缀**,
|
||||
再扫描全部 `*Controller.java`,把「类级路径 + 方法级路径」拼成完整路径,
|
||||
校验每个含 `/operator/` 的端点都被覆盖(`/auth/operator/**` 按设计排除)。
|
||||
|
||||
两个必须注意的细节:
|
||||
|
||||
- **只能解析 `.hasRole("OPERATOR")` 那条链的规则。** 若把 permitAll 或兜底的 `/**` 也收进来,
|
||||
任何路径都会被判为"已覆盖",守卫就退化成永远通过的摆设。
|
||||
- **工作目录是仓库根而非模块根**(同项目 `MigrationValidationTest` 也用 `backend/target/...` 形式),
|
||||
故源码根需同时尝试两种候选路径。
|
||||
|
||||
**负向验证**:摘掉 `/content/operator/**` 前缀后,恰好 1 个用例变红,
|
||||
并精确列出受影响的 6 个 `ContentController` 端点;恢复后全绿。
|
||||
|
||||
### 6. 验证
|
||||
|
||||
- E2E **40 passed**(原 37 + 3 条身份隔离用例:运营打用户端 403、
|
||||
用户打运营端 403、嵌套运营端点仍要求 OPERATOR)。
|
||||
- 后端全量回归 **664 / 0 failures / 0 errors / 3 skipped**(本轮起点 662,净 +2 为新守卫),
|
||||
`SecurityPathCoverageTest` 已并入常规回归。
|
||||
- 首个 E2E 轮次因非法路径模式导致 **33 failed**(过滤器链 500);
|
||||
修正为显式前缀后全部恢复,佐证了"`/**/operator/**` 不可用"这一结论。
|
||||
|
||||
---
|
||||
|
||||
## 八、结论
|
||||
|
||||
本轮把「失败必须携带真实 HTTP 语义」从约定变成了机制:
|
||||
|
||||
- 状态码的**唯一来源**是 `ApiResponseCode.httpStatus`,新增业务码不声明就编译不过;
|
||||
- 105 处 Controller 直返**零改动**即获得正确状态;
|
||||
- 未认证与无权限在 HTTP 层分离,小程序终于能触发重新登录;
|
||||
- CORS 默认从「放行任意来源」改为「默认拒绝」。
|
||||
- CORS 默认从「放行任意来源」改为「默认拒绝」;
|
||||
- 兜底访问规则从「已认证」收紧为「用户身份」,196 处潜在 NPE → 500 在认证层消解,
|
||||
并有机械守卫防止将来新增运营端点时漏配规则。
|
||||
|
||||
顺带修掉了一个当时就会伤害用户的缺陷:token 过期不再让用户卡死。
|
||||
顺带修掉了两个当时就会伤害真实用户的缺陷:
|
||||
**token 过期后用户卡死无法重登录**,以及**运营身份可打进用户端触发 500**。
|
||||
|
||||
@@ -86,3 +86,50 @@ def test_not_found_is_used_to_avoid_existence_probing(api, user_token):
|
||||
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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user