|
|
@@ -70,9 +70,33 @@ class LeshouyingAdminWiringTest {
|
|
|
private static final String OPENAPI_SUB_PATH = "/payment/openapi/pay/order/query";
|
|
|
private static final String OPENAPI_BARE_PREFIX = "/payment/openapi/pay";
|
|
|
|
|
|
+ /**
|
|
|
+ * {@code ROLE_} 前缀规范化之后的超管权限名 —— 被守的控制器里**唯一**合法的角色门禁。
|
|
|
+ *
|
|
|
+ * <p>串的来路(按符号定位,不是推断):{@code UserDetailsServiceImpl:72}
|
|
|
+ * 用 {@code new SimpleGrantedAuthority("ROLE_SUPERUSER")} 装配;{@code PermissionCheck}
|
|
|
+ * 的 {@code hasAny/hasAll} 也按这一串短路放行。
|
|
|
+ *
|
|
|
+ * <p>写成带前缀的形态,是因为比较前会先按 {@code PermissionCheck.hasRole} 的规则规范化
|
|
|
+ * (缺 {@code ROLE_} 就补上)—— 两种等价写法都收,免得一次等价改写变成假红。
|
|
|
+ */
|
|
|
+ private static final String ROLE_SUPERUSER_AUTHORITY = "ROLE_SUPERUSER";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * {@code @bean.hasRole('X')} 这个调用形状。
|
|
|
+ *
|
|
|
+ * <p>★ 认的是**调用的方法名**,不是字面量的形状:拿「大写、无冒号」当角色名的判据,
|
|
|
+ * 会把一个**真写错的权限串**(大写)当成角色名放过去 —— 那是把一条该红的错法洗成绿的。
|
|
|
+ * 宽松处理空白({@code @perm . hasRole ( 'X' )})是刻意的:SpEL 是字符串,重排空白是合法改动。
|
|
|
+ */
|
|
|
+ private static final Pattern ROLE_CALL =
|
|
|
+ Pattern.compile("@[A-Za-z_$][A-Za-z0-9_$]*\\s*\\.\\s*hasRole\\s*\\(\\s*'([^']*)'");
|
|
|
+
|
|
|
private static final String[] ADMIN_PATHS = {
|
|
|
"/payment/collection/order/page",
|
|
|
"/payment/collection/order/{outTradeNo}",
|
|
|
+ // 2026-09-30 追加:本通道第一个**动钱**的管理端端点,更不能被挂到 API Key 身份上。
|
|
|
+ "/payment/collection/order/{outTradeNo}/refund",
|
|
|
"/payment/collection/refund/page",
|
|
|
"/payment/collection/refund/{outRefundNo}",
|
|
|
"/payment/collection/merchant/page",
|
|
|
@@ -107,10 +131,16 @@ class LeshouyingAdminWiringTest {
|
|
|
* **落在契约清单里**」。清单只扫 `lsy-menu.sql` 时,`cashier` 那三条串会全部被判成
|
|
|
* 「授不出来的野串」—— 也就是**新控制器一登记就必然变红**。
|
|
|
* 反过来说:漏扫一份脚本的后果是**假红**(不是假绿),这个方向是安全的。
|
|
|
+ *
|
|
|
+ * <p>★ 2026-09-30 追加第三份 `db/lsy-order-refund-menu.sql`(管理端退款按钮的权限点)。
|
|
|
+ * 那份脚本单独立文件(不并进 `lsy-menu.sql`)的理由与汇安融那批相同:
|
|
|
+ * `lsy-menu.sql` 是「全新库安装」用的版本、且**已在线上执行过** ——
|
|
|
+ * 往里面补一行对现有库**不会生效**(目录守卫命中后整段不插且不报错)。
|
|
|
*/
|
|
|
private static final String[] MENU_SQLS = {
|
|
|
"db/lsy-menu.sql",
|
|
|
"db/lsy-cashier-menu.sql",
|
|
|
+ "db/lsy-order-refund-menu.sql",
|
|
|
};
|
|
|
|
|
|
@Test
|
|
|
@@ -314,6 +344,7 @@ class LeshouyingAdminWiringTest {
|
|
|
}
|
|
|
|
|
|
Set<String> declaredPermissions = new LinkedHashSet<>();
|
|
|
+ Set<String> roles = new LinkedHashSet<>();
|
|
|
for (Map.Entry<Class<?>, List<Method>> entry : discovered.entrySet()) {
|
|
|
Class<?> type = entry.getKey();
|
|
|
String base = classMappingPath(type);
|
|
|
@@ -338,9 +369,19 @@ class LeshouyingAdminWiringTest {
|
|
|
assertNotNull(pa, where + " 缺 @PreAuthorize —— 该端点会**同时**失去认证与租户条件"
|
|
|
+ "(TenantInnerInterceptor 只在已认证请求上追加 tenant_id),"
|
|
|
+ "而本控制器的类 javadoc 正是以「所有端点都有认证」为前提的");
|
|
|
- Set<String> perms = quotedLiterals(pa.value());
|
|
|
- assertFalse(perms.isEmpty(), where + " 的 @PreAuthorize 里取不到权限串: " + pa.value());
|
|
|
- declaredPermissions.addAll(perms);
|
|
|
+ Set<String> quoted = quotedLiterals(pa.value());
|
|
|
+ assertFalse(quoted.isEmpty(), where + " 的 @PreAuthorize 里一个引号字面量都没有 ⇒ "
|
|
|
+ + "要么注解被写成了不带参数的形状,要么下面的取数式失效了: " + pa.value());
|
|
|
+ // ★ 引号字面量要**按它出现在哪个调用里**分类(2026-09-30):
|
|
|
+ // `hasRole('X')` 的 X 是**角色名**,其余才是权限串。
|
|
|
+ // 不分类的话,`hasRole('SUPERUSER')` 会被当成权限串去和菜单契约比对
|
|
|
+ // ⇒ 报「授不出来的野串 SUPERUSER」——一条**假红**,而修它的人会去动菜单 SQL。
|
|
|
+ // 分类依据是 SpEL 里**实际调用的方法名**,不是字符串的形状,
|
|
|
+ // 所以写成大写/无冒号的**权限串**(真错法)照样落在 permissionLiterals 里、照样红。
|
|
|
+ for (String r : roleLiterals(pa.value())) {
|
|
|
+ roles.add(normalizeRole(r)); // 收集时就规范化,腿③之三与它逐字比
|
|
|
+ }
|
|
|
+ declaredPermissions.addAll(permissionLiterals(pa.value()));
|
|
|
|
|
|
// ── 腿④:SpEL 引用的 bean 名 == @Component("perm") 的注册名 ──────────────
|
|
|
assertEquals(Set.of(registeredBean), beanNamesIn(pa.value()),
|
|
|
@@ -360,6 +401,24 @@ class LeshouyingAdminWiringTest {
|
|
|
+ "都不存在的权限串 ⇒ 该权限永远授不出来(非超管一律 403): " + strays
|
|
|
+ ";契约清单=" + menuPermissions);
|
|
|
|
|
|
+ // ── 腿③之三:身份门禁里**只允许 SUPERUSER**(2026-09-30)───────────────────────
|
|
|
+ // 语义上没有「给某个角色开个后门」这回事,两个理由各自独立:
|
|
|
+ // ① 角色码是**数据**(sys_role 里可被超管新增/改名),而 is_superuser 是**列**;
|
|
|
+ // 权限串那一侧至少还有一份菜单 SQL 当契约,角色名没有任何可对齐的事实来源;
|
|
|
+ // ② 更要紧的是:除 SUPERUSER 外**没有任何角色会 bypass 租户拦截器**
|
|
|
+ // (TenantInnerInterceptor.ignoreTable 只认 isSuperuser)。被别的角色码放行的调用方
|
|
|
+ // 以为自己能跨租户写,而 SQL 上又被追加了 tenant_id —— 一边拒绝一边以为放行,
|
|
|
+ // 三种组合(写入被改写 / 0 行 / 报错)没有一种能在界面上说清。
|
|
|
+ // ★ 确实需要「让某个角色也能建号」时,做法是**权限串** + 在服务层显式校验归属,
|
|
|
+ // 不是把这里的 SUPERUSER 换成角色码。
|
|
|
+ // ★ 比的是 `ROLE_` 前缀规范化之后的名字,因为 PermissionCheck.hasRole 自己就接受两种写法
|
|
|
+ // (`SUPERUSER` 与 `ROLE_SUPERUSER` 等价)——只认一种的话,一次等价改写会变成假红。
|
|
|
+ Set<String> unknownRoles = new LinkedHashSet<>(roles);
|
|
|
+ unknownRoles.remove(ROLE_SUPERUSER_AUTHORITY);
|
|
|
+ assertTrue(unknownRoles.isEmpty(), "管理端 @PreAuthorize 里出现了 SUPERUSER 之外的角色门禁: "
|
|
|
+ + unknownRoles + "(实测全部角色门禁=" + roles + ")。见本段代码上方的两条理由;"
|
|
|
+ + "要让某个角色也能建号,请改用权限串并在服务层显式校验归属");
|
|
|
+
|
|
|
// ── 腿⑤:登记**完备**性(把上面那条腿缺的那个方向补上)──────────────────────
|
|
|
// 上面只断言 declared ⊆ contract。这个方向对「**新控制器忘了登记**」是**瞎的**:
|
|
|
// 把 LeshouyingCashierController 从 ADMIN_CONTROLLERS 里删掉,declared 只是少三条串,
|
|
|
@@ -384,6 +443,53 @@ class LeshouyingAdminWiringTest {
|
|
|
+ "它的菜单脚本到 MENU_SQLS): " + unbacked);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 商户号的**写入口只认超管身份**,不认权限串(2026-09-30,用户裁定
|
|
|
+ * 「新增商户号是超管操作,需要选择租户,租户不允许自己录入商户号」)。
|
|
|
+ *
|
|
|
+ * <p>【为什么必须是身份而不是权限串】权限串是「授给哪个角色、哪个角色就能调」,
|
|
|
+ * 而本端点的约束是**身份**。这不是审美:只有超管 bypass 了
|
|
|
+ * {@code TenantInnerInterceptor},别的角色没有 —— 一个被权限串放行的非超管调用方
|
|
|
+ * 可以**指定任意 tenantId**,而 insert 时既不会被改写成自己的租户
|
|
|
+ * (显式给值 ⇒ 列清单无条件含 tenant_id ⇒ 拦截器的追加分支被短路),也不会报错。
|
|
|
+ * ⇒ 权限串那条路等于把「替任意租户建号」开给了任何拿到该串的角色,而这一行决定的是
|
|
|
+ * 钱进哪个乐收赢商户号。
|
|
|
+ *
|
|
|
+ * <p>★ 与 HAR 的 {@code HarMerchantController#save/delete} 同一条裁定、同一个理由
|
|
|
+ * (仓库里这个形状的第一个实例)。差别是**那边没有同类守卫**:本类只守
|
|
|
+ * {@link #ADMIN_CONTROLLERS} 里登记过的控制器,而 HAR 那个不在其中。
|
|
|
+ *
|
|
|
+ * <p>会变红的改动:把任一个改回 {@code @perm.hasAny('module_payment:leshouying:merchant:list')}
|
|
|
+ * —— 那正是这两个端点改动前的样子,也是「照抄同文件其它方法的形状」最容易抄出来的样子。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void merchantWriteEndpoints_areGatedBySuperuserIdentity_notByAPermissionString() {
|
|
|
+ for (String name : List.of("save", "delete")) {
|
|
|
+ Method m = Arrays.stream(LeshouyingMerchantController.class.getDeclaredMethods())
|
|
|
+ .filter(x -> x.getName().equals(name))
|
|
|
+ .findFirst()
|
|
|
+ .orElseThrow(() -> new AssertionError(
|
|
|
+ "LeshouyingMerchantController 里找不到 " + name + " —— 端点被删或改名后,"
|
|
|
+ + "本用例会**静默地什么都不守**,故这里响亮失败"));
|
|
|
+ PreAuthorize pa = m.getAnnotation(PreAuthorize.class);
|
|
|
+ assertNotNull(pa, name + " 缺 @PreAuthorize(同 everyAdminMappedEndpoint_… 的腿②)");
|
|
|
+
|
|
|
+ Set<String> normalized = new LinkedHashSet<>();
|
|
|
+ for (String r : roleLiterals(pa.value())) {
|
|
|
+ normalized.add(normalizeRole(r));
|
|
|
+ }
|
|
|
+ assertEquals(Set.of(ROLE_SUPERUSER_AUTHORITY), normalized,
|
|
|
+ name + " 的角色门禁不是超管(或压根没有身份门禁)。实测: " + pa.value());
|
|
|
+
|
|
|
+ Set<String> perms = permissionLiterals(pa.value());
|
|
|
+ assertTrue(perms.isEmpty(), name + " 上还挂着权限串 " + perms
|
|
|
+ + " —— 身份门禁与权限串是两种判据;同时挂上时超管两种都过,"
|
|
|
+ + "于是没人会发现「授了这个权限串的角色其实仍然进不来」,"
|
|
|
+ + "而界面上一旦按权限串显示按钮,非超管就会看到一个点了必 403 的入口。"
|
|
|
+ + "实测: " + pa.value());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 取生产 `TenantApiKeyAuthFilter.OPENAPI_PREFIXES` 的**唯一**反射读取点。
|
|
|
* 两处用它,保证读到的是同一个数组、同一份语义。
|
|
|
@@ -471,6 +577,42 @@ class LeshouyingAdminWiringTest {
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * SpEL 里 {@code hasRole('X')} 的 X,**原样**(不规范化)—— 门禁里被引用的角色名。
|
|
|
+ *
|
|
|
+ * <p>与 {@link #quotedLiterals} 的分工见 {@link #permissionLiterals}。
|
|
|
+ */
|
|
|
+ private static Set<String> roleLiterals(String spel) {
|
|
|
+ Set<String> out = new LinkedHashSet<>();
|
|
|
+ Matcher m = ROLE_CALL.matcher(spel);
|
|
|
+ while (m.find()) {
|
|
|
+ out.add(m.group(1));
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 规范化成 authorities 里的名字:缺 {@code ROLE_} 前缀就补上。
|
|
|
+ * 规则**照抄** {@code PermissionCheck.hasRole} 的第一行(那边是 {@code startsWith} 判断,
|
|
|
+ * 这里是等价的正向表达)——两边漂移的后果是把一次等价改写判成假红。
|
|
|
+ */
|
|
|
+ private static String normalizeRole(String role) {
|
|
|
+ return role.startsWith("ROLE_") ? role : "ROLE_" + role;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SpEL 里的**权限串**:所有引号字面量**减去** {@code hasRole(...)} 的参数。
|
|
|
+ *
|
|
|
+ * <p>★ 为什么要减去:{@link #MENU_SQLS} 是权限串的契约来源,而角色名不在任何一份菜单 SQL 里
|
|
|
+ * —— 不减的话 {@code hasRole('SUPERUSER')} 会被判成「授不出来的野串」,是一条**假红**。
|
|
|
+ * 分类依据必须是调用形状(见 {@link #ROLE_CALL}),不能是字面量的形状。
|
|
|
+ */
|
|
|
+ private static Set<String> permissionLiterals(String spel) {
|
|
|
+ Set<String> out = quotedLiterals(spel);
|
|
|
+ out.removeAll(roleLiterals(spel));
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
/** SpEL 里所有 bean 引用({@code @beanName.})的名字。 */
|
|
|
private static Set<String> beanNamesIn(String spel) {
|
|
|
Set<String> out = new LinkedHashSet<>();
|