Parcourir la source

test(进件): 把评审自报没复现的三项补成独立读数 —— 签名原文规则 / 启动拒绝路径 / 租户拦截器四行表

「继续推剩下的」。交付说明 §11.2 的标题是「**评审**自报没复现的 8 项」——
它是一条关于评审的定语,不是本批的待办清单。8 项里不需要凭据的是 4、7、8;
先量它们是不是真的没覆盖,再各补一条钉子。只动 src/test,src/main 逐字节未变。

验签原文规则(第 4 项):本类其余 7 条 verify* 的期望值全部来自 sign(),
只证明「verify 与 sign 自洽」,不证明「verify 认的规则 == 上游文档的规则」。
新增 verify_acceptsSignBuiltFromTheDocumentedVectorIndependently —— 期望签名由本用例
按文档原文规则独立算出(不经 sign()),入站报文键序故意打乱,带反向守卫(改一字节必须拒)。

启动拒绝路径(第 7 项):validate() 的抛出有直接调用级覆盖,但「抛在 @PostConstruct 里
⇒ 上下文 refresh 真的失败」从未被观测 —— 全仓唯一容器入口吃 application.yml,
而那份 yml 第 13 行钉着 spring.profiles.active: dev,走的是放行分支。
新增 LeshouyingPropertiesStartupTest(ApplicationContextRunner,只注册本 bean + 绑定后处理器,
不起 DataSource、不要库隧道)。三臂是单变量对照:prod + 文档测试配置 ⇒ 起不来;
dev + 逐字相同的配置 ⇒ 起得来,两条正对照分别证明属性绑定与 @Value 解析真的发生
(少了任一条,「只差一格」就退化成「两臂以同一方式失败」);prod + 空 key ⇒ 第一道闸先炸。

四行表(第 8 项):原两条只覆盖第 2、4 行,第 1 行(超管)与第 3 行(已登录 tenantId==null)
零钉子 —— Step 0 量过六行,但量具是临时探针,报告自述源码与产物都已不在、重跑不了。
补齐两条,承重断言都用一个必定不在任何集合里的表名:证明的是「短路 / 不追加」的作用域,
而不是「这几张表恰好被跳过」(只断言四张 lsy 表的话,把它们挪进 IGNORE_TABLES 同样会绿)。

两向变异 5 条(字节级替换、保护行尾、带备份自验):
- m1 摘 @PostConstruct ⇒ 新类 2 红,而直接调 validate() 的 13 条全绿(容器级 vs 调用级)
- m2 排序反向 ⇒ 出站 3 条 + 新用例红,而 8 条 verify/sign 自洽用例全绿(第 4 项的实证)
- m3 isDevProfile 恒真 ⇒ 臂一 + 既有 1 条红;m4 超管分支取反 ⇒ 1 红;
  m5 条件表谓词取反 ⇒ 2 红。五轮还原后三个源文件与 baseline 逐字节相同。

全量 mvn test = 658 绿 / 0 失败 / 0 错误,BUILD SUCCESS(交付说明记的 652 + 本轮 6 条);
PaymentApplicationContextTest 亦绿 ⇒ 库隧道在,不是把「隧道断了」误读成回归。

顺带量清一件事:第二道闸在生产可达 —— docker-compose.yml:10、deploy.sh:16、
docs/ops.md:21/134、.claude/plan/java-deployment.md:121 五处都注入 SPRING_PROFILES_ACTIVE=prod,
覆盖 yml 的 dev 默认值。「忘注入 LESHOUYING_KEY 就拿文档公开密钥收单」真的会被挡。
alphaH il y a 3 semaines
Parent
commit
21ab7e8f46

+ 72 - 0
java/src/test/java/com/payment/platform/core/tenant/TenantInnerInterceptorTest.java

@@ -1,6 +1,7 @@
 package com.payment.platform.core.tenant;
 
 import com.payment.platform.core.security.LoginUser;
+import net.sf.jsqlparser.expression.LongValue;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -8,6 +9,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
 
 import java.util.List;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -61,4 +63,74 @@ class TenantInnerInterceptorTest {
         assertFalse(handler.ignoreTable("pay_lsy_notify_log"));
         assertFalse(handler.ignoreTable("pay_lsy_merchant"));
     }
+
+    /**
+     * 四行表的**第 1 行**:超管 ⇒ `ignoreTable` 恒真。
+     *
+     * <p>★ <b>为什么此前没有钉子</b>:Step 0 那张六行探针表(`task-7-report.md` §1)测过这一行,
+     * 但那是**临时** JUnit 探针 —— 报告自述「源码与编译产物都已不在」,重跑不了。
+     * 本类原有的两条只覆盖第 2 行(普通租户用户)与第 4 行(无认证)。
+     *
+     * <p>★ <b>断言的是「短路」,不是「这四张表被跳过」</b>:用一个**必定不在任何集合里**的名字,
+     * 超管下它也必须返回 `true` —— 这才证明超管分支排在 `CONDITIONAL_TABLES` 与末尾
+     * `return false` **之前**。只断言四张 lsy 表的话,把它们挪进 `IGNORE_TABLES` 同样会绿。
+     *
+     * <p>★ 另一半是**非承重但要紧的**:超管**只**让 `ignoreTable` 短路,`getTenantId()` 照常算出
+     * 它自己的租户号(不是兜底的 0)。两者是独立判断 —— 合一之后「超管」这个身份就会
+     * 悄悄变成「无租户」。
+     */
+    @Test
+    void ignoreTable_superuser_shortCircuitsEveryTableButStillResolvesItsTenant() {
+        LoginUser admin = new LoginUser();
+        admin.setIsSuperuser(true);
+        admin.setTenantId(1L);
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(admin, null, List.of()));
+
+        var handler = new TenantInnerInterceptor().getTenantLineHandler();
+
+        assertTrue(handler.ignoreTable("pay_lsy_order"));
+        assertTrue(handler.ignoreTable("pay_lsy_refund"));
+        assertTrue(handler.ignoreTable("pay_lsy_notify_log"));
+        assertTrue(handler.ignoreTable("pay_lsy_merchant"));
+        // ★ 承重件:超管是**与表名无关**的短路,而不是「这几张表恰好被跳过」。
+        assertTrue(handler.ignoreTable("some_unlisted_table"));
+
+        assertEquals(1L, ((LongValue) handler.getTenantId()).getValue(),
+                "超管只让 ignoreTable 短路;getTenantId() 仍应算出它自己的租户号");
+    }
+
+    /**
+     * 四行表的**第 3 行**:已登录 `LoginUser` 但 `tenantId == null` ⇒ 四张条件表**不追加**谓词。
+     *
+     * <p>★ 这一行是 Step 0 认定的**真实暴露面**之一(brief「换后的理由」第 1 条):
+     * 本仓多数读端点的行级隔离**只**靠这条 `CONDITIONAL_TABLES` 分支,
+     * 而本行说它对无租户主体**不追加** —— 这正是 `commit` 端点必须自带显式谓词的原因。
+     *
+     * <p>★ <b>反向守卫把范围钉死</b>:同一个主体对**未列入**的表仍返回 `false`(走末尾
+     * `return false`,照常追加 —— 此时 `getTenantId()` 兜底为 0,等于查不到任何行)。
+     * 少了这句,「不追加」读起来会像「过滤被整个关掉了」。
+     *
+     * <p>★ 本行所描述的主体在**当前部署造不出来**(Step 0 库侧读数:`sys_user` 33 行里
+     * `tenant_id IS NULL` 为 0 行)—— 它的意义是「将来若出现这样的账号」,
+     * 而不是「今天有人在这么用」。
+     */
+    @Test
+    void ignoreTable_authenticatedButTenantless_skipsConditionalTablesOnly() {
+        LoginUser user = new LoginUser();
+        user.setTenantId(null);
+        user.setIsSuperuser(false);
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(user, null, List.of()));
+
+        var handler = new TenantInnerInterceptor().getTenantLineHandler();
+
+        assertTrue(handler.ignoreTable("pay_lsy_order"));
+        assertTrue(handler.ignoreTable("pay_lsy_refund"));
+        assertTrue(handler.ignoreTable("pay_lsy_notify_log"));
+        assertTrue(handler.ignoreTable("pay_lsy_merchant"));
+
+        assertFalse(handler.ignoreTable("some_unlisted_table"),
+                "「不追加」只发生在 CONDITIONAL_TABLES 这几张表上,不是全局关掉了过滤");
+    }
 }

+ 44 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSignerTest.java

@@ -130,4 +130,48 @@ class LeshouyingSignerTest {
         p.put("sign", signer.sign(p, KEY).toUpperCase());
         assertTrue(signer.verify(p, KEY));
     }
+
+    /**
+     * ★ <b>`verify` 的独立向量</b> —— 期望签名**不由 `sign()` 产出**,而由本用例按上游文档的
+     * 原文规则独立算出。
+     *
+     * <p>★ <b>为什么单列一条</b>:本类其余 7 条 `verify*` 用例的期望值**全部来自 `sign()`**,
+     * 它们证明的是「verify 与 sign 自洽」,**不是**「verify 认的原文规则 == 上游文档的规则」。
+     * 而入站验签是回调入口的**唯一身份证明**(见类注释的【安全约束】)——
+     * 这条是唯一一条把**外部给定**的原文规则喂进**入站**路径的用例。
+     *
+     * <p>向量逐字来自上游文档 §「签名规则」第 2 步给出的字符串 A
+     * (与 {@link #sign_matchesDocumentedParamSet} 同一份,那条走的是**出站** `sign()`)。
+     *
+     * <p>★ <b>反向守卫</b>:同一向量改一个字节(`total_amount` 1→2)必须被拒 ——
+     * 少了它,上面那句 `assertTrue` 在「verify 恒返回 true」下也会绿。
+     */
+    @Test
+    void verify_acceptsSignBuiltFromTheDocumentedVectorIndependently() {
+        String docA = "auth_code=134573607383541818"
+                + "&inst_no=900075"
+                + "&mch_no=Y52211090004"
+                + "&pay_time=20190101125959"
+                + "&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e"
+                + "&pay_type=400"
+                + "&total_amount=1";
+        String documentedSign = cn.hutool.crypto.digest.DigestUtil
+                .md5Hex(docA + "&key=" + KEY).toLowerCase();
+
+        // 入站报文的键序**故意与文档的排序结果不同** —— 排序必须由 verify 自己完成。
+        Map<String, Object> inbound = params(
+                "pay_trace_no", "18588dbd1c6e4572b915a2b33fcdc62e",
+                "total_amount", "1",
+                "pay_type", "400",
+                "mch_no", "Y52211090004",
+                "auth_code", "134573607383541818",
+                "pay_time", "20190101125959",
+                "inst_no", "900075");
+        inbound.put("sign", documentedSign);
+
+        assertTrue(signer.verify(inbound, KEY), "入站路径必须认上游文档给定的原文规则");
+
+        inbound.put("total_amount", "2");
+        assertFalse(signer.verify(inbound, KEY), "反向守卫:改一个字节必须被拒");
+    }
 }

+ 142 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesStartupTest.java

@@ -0,0 +1,142 @@
+package com.payment.platform.module.payment.leshouying.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Configuration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * `validate()` 的**启动拒绝路径** —— 在**真容器**里跑,而不是直接调方法。
+ *
+ * <p>★ <b>为什么需要独立一条</b>(§11.2 第 7 项):{@link LeshouyingPropertiesTest} 用
+ * `p::validate` 直接调,覆盖的是**决策逻辑**;而本闸的承诺是**「启动中止」/「拒绝启动」**——
+ * 从「`validate()` 会抛」到「应用起不来」之间还隔着 Spring 的 `@PostConstruct` 调用链。
+ * 那段此前**从未被观测过**:全仓唯一的容器入口 `PaymentApplicationContextTest`
+ * (`@SpringBootTest`)吃 `src/main/resources/application.yml`,而那份 yml 第 13 行钉着
+ * `spring.profiles.active: dev` ⇒ 它走的是 `log.warn` 那条**放行**分支。
+ * 也就是说:拒绝分支在执行过的容器里一次都没响过。
+ *
+ * <p>★ <b>这道闸在生产的可达性(2026-09-23 实测)</b>:生产不是靠 yml 那个 `dev`
+ * 生效的 —— `docker-compose.yml:10`、`deploy.sh:16`、`docs/ops.md:21/134` 与
+ * `.claude/plan/java-deployment.md:121` 全部注入 `SPRING_PROFILES_ACTIVE=prod`,
+ * 覆盖掉 yml 的默认值。⇒ 「忘注入 `LESHOUYING_KEY` 就拿文档公开密钥收单」这条路是真的会被挡,
+ * 不是纸面承诺。
+ *
+ * <p>★ <b>量具的选择与代价</b>:`ApplicationContextRunner` 只注册本 bean 与绑定后处理器,
+ * **不扫描、不自动配置** ⇒ 不起 DataSource、不需要库隧道、不需要 Redis
+ * (区别于 `PaymentApplicationContextTest` 的已知硬依赖)。代价是它**不覆盖**
+ * 「本 bean 真的在 `@ComponentScan` 范围内」这件事 —— 那一条仍由
+ * `PaymentApplicationContextTest` 负责,两者不互相替代。
+ *
+ * <p>★ <b>三条臂是一次单变量对照</b>:臂一与臂二**只差** `spring.profiles.active` 一格,
+ * 其余属性逐字相同 ⇒ 臂一的失败只能是 profile 闸造成的,而不是「属性没绑上」。
+ * 臂二的断言就是为这个「只差一格」作证的。
+ */
+class LeshouyingPropertiesStartupTest {
+
+    /** 只注册这一个 bean + `@ConfigurationProperties` 绑定后处理器。 */
+    @Configuration(proxyBeanMethods = false)
+    @EnableConfigurationProperties(LeshouyingProperties.class)
+    static class Cfg {
+    }
+
+    /** 与 {@code LeshouyingPropertiesTest.docTestProps()} 逐字同值,免得两处各自漂。 */
+    private static final String NOTIFY_URL =
+            "https://example.com/api/v1/payment/collection/notify/pay";
+
+    /**
+     * 造出「`application.yml` 的三项默认值原样生效」的那个场景 ——
+     * 即**忘设环境变量**时的真实取值。三个常量本身就是 yml 里给的默认值。
+     */
+    private static ApplicationContextRunner docTestConfig() {
+        return new ApplicationContextRunner()
+                .withUserConfiguration(Cfg.class)
+                .withPropertyValues(
+                        "leshouying.base-url=" + LeshouyingProperties.DOC_TEST_BASE_URL,
+                        "leshouying.inst-no=" + LeshouyingProperties.DOC_TEST_INST_NO,
+                        "leshouying.key=" + LeshouyingProperties.DOC_TEST_KEY,
+                        "leshouying.notify-url=" + NOTIFY_URL);
+    }
+
+    /** 把异常链上的消息拼起来 —— 根因常常埋在 `BeanCreationException` 的 cause 里。 */
+    private static String chain(Throwable t) {
+        StringBuilder sb = new StringBuilder();
+        for (Throwable c = t; c != null; c = c.getCause()) {
+            sb.append(c.getClass().getSimpleName()).append(": ").append(c.getMessage()).append(" | ");
+            if (sb.length() > 4000) {
+                break;
+            }
+        }
+        return sb.toString();
+    }
+
+    @Test
+    void prodProfile_withDocTestConfig_failsContextStartup() {
+        docTestConfig().withPropertyValues("spring.profiles.active=prod")
+                .run(context -> {
+                    Throwable failure = context.getStartupFailure();
+                    assertNotNull(failure,
+                            "非 dev 环境拿文档测试配置时必须**起不来**(本闸的承诺就是「启动中止」)");
+                    String all = chain(failure);
+                    // 消息里带上整条链:万一断言不成立,读输出就能看见真正抛的是什么。
+                    assertTrue(all.contains("文档测试"),
+                            "起不来的根因必须是第二道闸(文档测试配置),实际链: " + all);
+                    assertTrue(all.contains("拒绝启动"),
+                            "必须是「拒绝启动」那条出口,实际链: " + all);
+                });
+    }
+
+    /**
+     * ★ <b>正对照</b>:同一份属性、只把 profile 换成 `dev` ⇒ 必须起得来。
+     *
+     * <p>两条断言各有分工,缺一条这组对照就不成立:
+     * <ul>
+     *   <li>{@code getKey()} 等于文档测试密钥 ⇒ **属性绑定真的发生了**。否则臂一的「拒绝」
+     *       可能只是「`key` 绑成了 null,撞上第一道闸」,而根因断言恰好也会红 —— 但那时
+     *       臂一证明的不是 profile 闸。</li>
+     *   <li>{@code getActiveProfiles()} 等于 `dev` ⇒ **`@Value` 真的解析到了**。否则两臂读到的
+     *       都是空串,而空串在 `isDevProfile()` 下按 fail-closed 算非 dev ⇒ 臂二也会抛,
+     *       「只差一格」的对照就退化成「两臂都以同一方式失败」。</li>
+     * </ul>
+     */
+    @Test
+    void devProfile_withDocTestConfig_startsAndBinds() {
+        docTestConfig().withPropertyValues("spring.profiles.active=dev")
+                .run(context -> {
+                    assertNull(context.getStartupFailure(),
+                            "dev 下文档测试配置必须放行(开发期开箱即用),实际: "
+                                    + chain(context.getStartupFailure()));
+                    LeshouyingProperties props = context.getBean(LeshouyingProperties.class);
+                    assertEquals(LeshouyingProperties.DOC_TEST_KEY, props.getKey(),
+                            "正对照:属性没有被绑定,臂一与臂二的差异不成立");
+                    assertEquals("dev", props.getActiveProfiles(),
+                            "正对照:profile 没有被解析到,两臂都在读空串");
+                });
+    }
+
+    /**
+     * 第一道闸(缺配置)同样要在容器里生效,且**先于**第二道闸。
+     *
+     * <p>判据是消息里必须是「配置缺失」而**不是**「文档测试」:两道闸的顺序反了的话,
+     * 一个 `key` 留空的非 dev 环境会被报成「你在用文档测试配置」—— 把人指向错误的方向
+     * (真正的问题是没注入,不是注入了测试值)。
+     */
+    @Test
+    void prodProfile_withBlankKey_failsOnTheMissingConfigGateFirst() {
+        docTestConfig()
+                .withPropertyValues("spring.profiles.active=prod", "leshouying.key=")
+                .run(context -> {
+                    Throwable failure = context.getStartupFailure();
+                    assertNotNull(failure, "缺 key 必须起不来");
+                    String all = chain(failure);
+                    assertTrue(all.contains("配置缺失"), "实际链: " + all);
+                    assertTrue(!all.contains("文档测试"),
+                            "第一道闸必须先炸,不该报成「文档测试配置」把人指向错误方向。实际链: " + all);
+                });
+    }
+}