Przeglądaj źródła

feat(汇安融): 收款链接(三)—— 装配接线:白名单 + 条件表 + 守卫测试

顾客页两个端点(2109cb6c)与管理端取链接端点(b00a8348)都到了,但缺了这两处
装配它们就是打不开的。本刀只接线与守卫,不改业务代码。

SecurityConfig.WHITE_LIST
- 新增 "/payment/har/checkout/**"(免登录,顾客页)。
- ★ 不能指望乐收赢那条 "/payment/checkout/**" 兜住:前缀不同(/payment/har/... 而非
  /payment/checkout/...),Ant 模式匹配不到 —— 守卫测试里把这条差异写成了前提复核
  (matcher.match("/payment/checkout/**", "/payment/har/checkout/tok") == false)。
- 漏了它 ⇒ 顾客打开链接被 Spring Security 挡成 401/403,而这条路径本来就不该有登录态。

TenantInnerInterceptor.CONDITIONAL_TABLES
- 新增 "pay_har_checkout_link"(pay_har_order 补尾逗号)。
- ★ 漏登记 ⇒ 未认证查询被追加 AND tenant_id = 0 ⇒ **每条链接都查不到**,
  表现是顾客页一律「链接无效」,而日志里一个字都没有。
- 论证沿用同形条目 pay_lsy_checkout_link 那段(通则 (甲)·预防):定位键是 token,
  而 uk_har_checkout_token **单独**建在 token 上 ⇒ 同一 token 命中两行在 schema 层
  建不出来 ⇒ 定位用 selectOne 是允许的。注释里写明与 LSY 条目的唯一差异(索引名)。

守卫测试(先看到红:2/4 失败于「未加入白名单 / 未加入条件表」)
- HarCheckoutWiringTest 4 条,与 LeshouyingWiringTest 同形:
  · 白名单用真 Ant 匹配器撞**模式**(存的是 /** 形态,contains 字面量恒 false)
    + 前提复核(LSY 条目覆盖不到本前缀,所以本登记不可省);
  · 反面对照:管理端 /payment/har/merchant(含 checkout-link)**不得**被白名单覆盖
    —— 否定式读数配正对照(先用确实放行的 /payment/openapi/har/** 证明量具是好的);
  · 条件表含四张 har 表(漏登记症状见上);
  · shouldNotFilter(顾客页) == true:顾客页不是下游系统,不该经 ApiKey 认证面
    (反向守卫:哪天有人把它挪进 /payment/openapi/** 立刻红)。
- 邻居复跑:HarCheckoutWiringTest 4 + LeshouyingWiringTest 8 = 12/12 绿
  (两处常量都是它们读的同一份)。
alphaH 1 tydzień temu
rodzic
commit
8b4619ba3a

+ 6 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -79,6 +79,12 @@ public class SecurityConfig {
             // ★ 放行的是**授权层**;租户隔离仍由 TenantInnerInterceptor 的条件表
             //   (pay_lsy_checkout_link 已登记)+ 控制器里显式绑定的租户上下文负责。
             "/payment/checkout/**",
+            // 汇安融收款链接(顾客页,免登录)—— 与上一行同形,但**路径前缀不同**
+            // (/payment/har/... 而非 /payment/checkout/...),上面那条 Ant 模式匹配不到它,
+            // 必须单独登记(HarCheckoutWiringTest 把这条差异写成了前提复核)。
+            // 身份同样不是认证来的:token 全局唯一(uk_har_checkout_token),
+            // 且是 32 位随机十六进制(见 HarCheckoutLinkService)。
+            "/payment/har/checkout/**",
             "/v3/api-docs/**",
             "/swagger-ui/**",
             "/swagger-ui.html",

+ 9 - 1
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -224,8 +224,16 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
                                      //     否则「未认证 + LIMIT 1」就是本清单禁止的静默挑行。
             "pay_har_merchant",      // 汇安融商户配置(下单时按 enterprise_id 反查,开放API无登录态)
             "pay_har_shop",          // 汇安融门店(开放API创建/查询无登录态)
-            "pay_har_order"          // 汇安融订单(上游通知回调 + 定时轮询均无认证上下文;
+            "pay_har_order",         // 汇安融订单(上游通知回调 + 定时轮询均无认证上下文;
                                      //   订单号为我方生成的雪花ID,全局唯一,无跨租户风险)
+            "pay_har_checkout_link"  // 收款链接(顾客页按 token 定位,**免登录**)。与上一段
+                                     //   pay_lsy_checkout_link 同形,逐条论证不再重抄;
+                                     //   ★ 差异只有一处:这里的唯一索引叫 uk_har_checkout_token,
+                                     //     同样**单独**建在 token 上(不是 (tenant_id, token))。
+                                     //   ★ 漏登记 ⇒ 未认证查询被追加 AND tenant_id = 0
+                                     //     ⇒ **每条链接都查不到**,表现是顾客页一律「链接无效」,
+                                     //     而且日志里一个字都没有。
+                                     //   ★ 定位用 selectOne 是**允许**的:因为走了 (甲)。
     );
 
     public TenantInnerInterceptor() {

+ 130 - 0
java/src/test/java/com/payment/platform/core/security/HarCheckoutWiringTest.java

@@ -0,0 +1,130 @@
+package com.payment.platform.core.security;
+
+import com.payment.platform.core.tenant.TenantInnerInterceptor;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.util.AntPathMatcher;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 汇安融收款链接(顾客页)那条腿的**装配**守卫 —— 与 {@code LeshouyingWiringTest} 同形。
+ *
+ * <p>这里钉的每一条都是「模块内的用例覆盖不到、漏改后症状又都长得不像自己」的东西:
+ * <ul>
+ *   <li><b>白名单</b>:{@code SecurityConfig.WHITE_LIST} 少了 {@code /payment/har/checkout/**}
+ *       ⇒ 顾客打开链接被 Spring Security 挡成 401/403,而这条路径**本来就不该有登录态**。
+ *       ★ 别指望乐收赢那条 {@code /payment/checkout/**} 兜住 —— 两者前缀不同,
+ *       Ant 模式匹配不到(本类第一条用例里有这条前提复核,写死)。</li>
+ *   <li><b>条件表</b>:{@code TenantInnerInterceptor.CONDITIONAL_TABLES} 少了
+ *       {@code pay_har_checkout_link} ⇒ 未认证查询被追加 {@code AND tenant_id = 0}
+ *       ⇒ **每条链接都查不到**,表现是顾客页一律「链接无效」,而日志里一个字都没有。</li>
+ *   <li><b>认证面</b>:它是顾客页,不是下游系统 ⇒ {@code TenantApiKeyAuthFilter.shouldNotFilter}
+ *       必须为 true(跳过)。反向守卫:哪天有人把顾客端点挪进 {@code /payment/openapi/**}
+ *       或往 {@code OPENAPI_PREFIXES} 里加前缀,它立刻红 —— 那种挪动会把一条**免登录**
+ *       路径变成「必须持有 ApiKey」。</li>
+ * </ul>
+ */
+class HarCheckoutWiringTest {
+
+    /** 顾客页的两个真实形状路径(开屏 / 下单)。 */
+    private static final String[] CHECKOUT_PATHS = {
+            "/payment/har/checkout/tok",
+            "/payment/har/checkout/tok/orders",
+    };
+
+    /** 被测 filter;依赖字段在 shouldNotFilter 路径上都不被触碰,故可传 null(同 LeshouyingWiringTest)。 */
+    private final TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null);
+
+    /**
+     * 顾客页两条路径必须被白名单放行。
+     *
+     * <p>★ 用真 Ant 匹配器撞**模式**,不是 {@code Arrays.asList(...).contains(字面量)}
+     * —— 白名单条目存的是 {@code /**} 形态,contains 对它恒为 false(同 LeshouyingWiringTest
+     * 里那段「近似代理」说明)。顺带一条前提复核:乐收赢那条 {@code /payment/checkout/**}
+     * **匹配不到**本前缀,所以这一条登记是**独立的、不可省的**(删掉它本用例立刻红,
+     * 而不是靠邻居兜住)。
+     */
+    @Test
+    void checkoutPaths_areWhitelisted_matchingAntPattern() throws Exception {
+        AntPathMatcher matcher = new AntPathMatcher();
+        // 前提复核:乐收赢那条白名单条目覆盖不到 /payment/har/checkout/**,本登记不可省
+        assertFalse(matcher.match("/payment/checkout/**", "/payment/har/checkout/tok"),
+                "前提复核:若这条成立于将来某次改写,说明前缀形态变了,本用例的独立断言要重审");
+
+        for (String path : CHECKOUT_PATHS) {
+            assertTrue(whitelisted(path),
+                    "顾客页路径未加入 SecurityConfig.WHITE_LIST(会被挡成 403): " + path);
+        }
+    }
+
+    /**
+     * 反面对照:管理端(要登录)的汇安融路径**不得**被白名单覆盖。
+     *
+     * <p>★ 否定式读数配正对照:先用一条**确实被放行**的同前缀路径证明匹配器与读取姿势是好的
+     * ({@code /payment/openapi/har/**} 命中),再断言管理端两条不被命中 ——
+     * 否则「没命中」可能只是量具坏了(本仓吃过这个亏)。
+     */
+    @Test
+    void adminHarEndpoints_areNotWhitelisted() throws Exception {
+        assertTrue(whitelisted("/payment/openapi/har/barcode/pay"),
+                "正对照:同前缀的开放接口确实被放行 —— 证明匹配器读取姿势是好的");
+
+        assertFalse(whitelisted("/payment/har/merchant"),
+                "管理端商户页必须登录:若有人把白名单写宽成 /payment/har/**,这条变红");
+        assertFalse(whitelisted("/payment/har/merchant/1/checkout-link"),
+                "管理端「取收款链接」必须登录(它是发放腿,租户号从登录主体推)");
+    }
+
+    @Test
+    void conditionalTables_containHarCheckoutLink() throws Exception {
+        Field f = TenantInnerInterceptor.class.getDeclaredField("CONDITIONAL_TABLES");
+        f.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Set<String> tables = (Set<String>) f.get(null);
+
+        for (String t : new String[]{"pay_har_merchant", "pay_har_shop", "pay_har_order",
+                                     "pay_har_checkout_link"}) {
+            assertTrue(tables.contains(t),
+                    "未加入 CONDITIONAL_TABLES → 无认证场景查询被追加 tenant_id=0 → 顾客页一律「链接无效」且日志无痕: " + t);
+        }
+    }
+
+    @Test
+    void checkoutPaths_doNotGoThroughApiKeyAuth() {
+        for (String path : CHECKOUT_PATHS) {
+            assertTrue(filter.shouldNotFilter(realRequest(path)),
+                    "顾客页不是下游系统,不该经 ApiKey 认证面(应当跳过该 filter): " + path);
+        }
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    /** 白名单是否覆盖该路径(读生产常量 + 真 Ant 匹配器,同 LeshouyingWiringTest 的写法)。 */
+    private static boolean whitelisted(String path) throws Exception {
+        Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
+        f.setAccessible(true);
+        String[] whiteList = (String[]) f.get(null);
+        AntPathMatcher matcher = new AntPathMatcher();
+        return Arrays.stream(whiteList).anyMatch(w -> matcher.match(w, path));
+    }
+
+    /**
+     * 构造**真实容器形状**的请求:servletPath 不含 context-path,requestURI 含。
+     * ★ 若两者相等(旧写法),测试就分辨不出被测代码用的是哪个访问器 ——
+     *   同 LeshouyingWiringTest#realRequest。
+     */
+    private static MockHttpServletRequest realRequest(String path) {
+        MockHttpServletRequest req = new MockHttpServletRequest("POST", path);
+        req.setContextPath("/api/v1");
+        req.setServletPath(path);
+        req.setRequestURI("/api/v1" + path);
+        assertNotEquals(req.getServletPath(), req.getRequestURI(),
+                "mock 必须是真实容器形状:servletPath ≠ requestURI,否则测不出访问器用错");
+        return req;
+    }
+}