|
@@ -0,0 +1,448 @@
|
|
|
|
|
+package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
+
|
|
|
|
|
+import ch.qos.logback.classic.Logger;
|
|
|
|
|
+import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
|
|
|
+import ch.qos.logback.core.read.ListAppender;
|
|
|
|
|
+import cn.hutool.json.JSONUtil;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingSigner;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
|
|
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
|
|
+import org.mockito.Mock;
|
|
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.function.Supplier;
|
|
|
|
|
+import java.util.regex.Matcher;
|
|
|
|
|
+import java.util.regex.Pattern;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 乐收赢审核通知接收端(上游 page 196236556)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>这是本批次唯一的免登录入口</b>(另两条免登录腿是既有的交易 / 退款回调),
|
|
|
|
|
+ * 所以本类的用例集中在两件事上:<b>身份(验签)</b>与<b>定位</b>。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★★ <b>定位那条闸为什么必须用「真服务 + 假 mapper」来测</b>:
|
|
|
|
|
+ * 定位不在本类里 —— 它在 {@code LsyMerchantService.updateReviewStatusByMchNo},
|
|
|
|
|
+ * 而那道闸的形状({@code selectList} 后自己判 0 / 1 / 多行)正是
|
|
|
|
|
+ * {@code onboardingNotify_duplicateMchNo_returnsFail} 要钉的东西。
|
|
|
|
|
+ * 若把 {@code LsyMerchantService} 整个 mock 掉,那条用例就退化成
|
|
|
|
|
+ * 「mock 说 false ⇒ 我回 FAIL」—— <b>把定位查询改成 {@code selectOne} + {@code LIMIT 1}
|
|
|
|
|
+ * 它照样绿</b>,等于没钉。所以这里用**真** {@code LsyMerchantService}
|
|
|
|
|
+ * (外面包一层 spy,只为能 verify「没走 {@code save}」),只把 mapper 换成假的。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 本类**不碰数据库**(mapper 是 mock),与 {@code mvn test} 的库隧道依赖无关。
|
|
|
|
|
+ */
|
|
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
|
|
+class LsyOnboardingNotifyServiceTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** 与 `LsyNotifyServiceTest` 同源:application.yml 里那个**文档测试**机构密钥。 */
|
|
|
|
|
+ private static final String KEY = "1c4d83f0c3c24a7d9fcaa1fed2682076";
|
|
|
|
|
+
|
|
|
|
|
+ /** 与库里那两行重复夹具同值 —— 见 `db/lsy-merchant-mch-no-uk.sql` 第 0.1 步的实测读数。 */
|
|
|
|
|
+ private static final String MCH_NO = "Y52211090004";
|
|
|
|
|
+
|
|
|
|
|
+ // ── 应答信封 ────────────────────────────────────────────────────────────────
|
|
|
|
|
+ // ★ 逐字断言,不写成 contains("result_code") —— 那样把键改名成 return_code 也照样绿。
|
|
|
|
|
+ // 信封是 result_code / result_msg(page 196236556 的**返回参数表**与**返回示例**逐字都是它),
|
|
|
|
|
+ // 与本控制器里另两条腿的 return_code / return_msg 是**两套**,见其类 javadoc。
|
|
|
|
|
+ private static final String OK = "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"}";
|
|
|
|
|
+ private static final String BAD_SIGN = "{\"result_code\":\"FAIL\",\"result_msg\":\"验签失败\"}";
|
|
|
|
|
+ private static final String BAD_NOT_FOUND = "{\"result_code\":\"FAIL\",\"result_msg\":\"商户号不存在\"}";
|
|
|
|
|
+ private static final String BAD_AMBIGUOUS = "{\"result_code\":\"FAIL\",\"result_msg\":\"商户号命中多行,拒绝回写\"}";
|
|
|
|
|
+
|
|
|
|
|
+ @Mock private LsyMerchantMapper merchantMapper;
|
|
|
|
|
+
|
|
|
|
|
+ private LeshouyingSigner signer;
|
|
|
|
|
+ private LsyMerchantService merchantService;
|
|
|
|
|
+ private LsyOnboardingNotifyService service;
|
|
|
|
|
+
|
|
|
|
|
+ /** 被 `update` 认领的那一行。 */
|
|
|
|
|
+ private LsyMerchantEntity row;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ signer = new LeshouyingSigner();
|
|
|
|
|
+ LeshouyingProperties props = new LeshouyingProperties();
|
|
|
|
|
+ props.setKey(KEY);
|
|
|
|
|
+
|
|
|
|
|
+ // spy 而不是 mock:真方法照跑(定位与写入都是真的),但能 verify「没调过哪个方法」。
|
|
|
|
|
+ merchantService = spy(new LsyMerchantService(merchantMapper));
|
|
|
|
|
+ service = new LsyOnboardingNotifyService(signer, props, merchantService);
|
|
|
|
|
+
|
|
|
|
|
+ row = new LsyMerchantEntity();
|
|
|
|
|
+ row.setId(2102313081860202497L);
|
|
|
|
|
+ row.setTenantId(2L);
|
|
|
|
|
+ row.setMchNo(MCH_NO);
|
|
|
|
|
+ row.setReviewStatus("PENDING");
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper / LambdaUpdateWrapper
|
|
|
|
|
+ // 在无 Spring 上下文的单测里可用(否则抛 MybatisPlusException: can not find lambda cache)。
|
|
|
|
|
+ // 同款引导见 LsyMerchantServiceTest / LsyNotifyServiceTest。
|
|
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyMerchantEntity.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────────────── 身份 ─────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 验签失败 ⇒ <b>不写任何业务状态</b> + 应答 FAIL。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>Global Constraint 2「验签绝不跳过」:入口无认证,签名是唯一身份证明。
|
|
|
|
|
+ * 断言分两层,缺一不可:<br>
|
|
|
|
|
+ * ① 应答是 FAIL(让上游重推 —— 报文可能是真的,只是签名不对);<br>
|
|
|
|
|
+ * ② <b>一条 SQL 都没发</b>。只断言 ① 的话,一个「先写库再验签」的实现照样绿。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_rejectsBadSign() {
|
|
|
|
|
+ Map<String, Object> body = onboardingBody("审核通过");
|
|
|
|
|
+ body.put("sign", "00000000000000000000000000000000");
|
|
|
|
|
+
|
|
|
|
|
+ String response = service.handleOnboardingNotify(body, json(body));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(BAD_SIGN, response, "验签失败必须应答 FAIL,实测: " + response);
|
|
|
|
|
+ verify(merchantMapper, never()).selectList(any());
|
|
|
|
|
+ verify(merchantMapper, never()).update(any(), any());
|
|
|
|
|
+ verify(merchantService, never()).updateReviewStatusByMchNo(any(), any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────────────── 值域 ─────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★★ 两种形态都认 —— page 196236556 **同一页**里参数表写数字({@code 1.审核通过,2.审核驳回})、
|
|
|
|
|
+ * 请求示例写中文({@code "reviewed_status":"审核通过"}),没有「哪一处更新」这种线索,
|
|
|
|
|
+ * 只能两种都认。只认一种 ⇒ 另一种的**每一条**通知都落进「未知值」分支(不落库)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_acceptsBothStatusShapes() {
|
|
|
|
|
+ Map<String, String> expected = new LinkedHashMap<>();
|
|
|
|
|
+ expected.put("1", "APPROVED");
|
|
|
|
|
+ expected.put("审核通过", "APPROVED");
|
|
|
|
|
+ expected.put("2", "REJECTED");
|
|
|
|
|
+ expected.put("审核驳回", "REJECTED");
|
|
|
|
|
+
|
|
|
|
|
+ for (Map.Entry<String, String> e : expected.entrySet()) {
|
|
|
|
|
+ row.setReviewStatus("PENDING"); // 每轮从同一个起点出发
|
|
|
|
|
+ stubOneRow();
|
|
|
|
|
+
|
|
|
|
|
+ String response = service.handleOnboardingNotify(
|
|
|
|
|
+ signed(onboardingBody(e.getKey())), "{}");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(OK, response, "形态 [" + e.getKey() + "] 应答是 " + response);
|
|
|
|
|
+ assertEquals(e.getValue(), row.getReviewStatus(),
|
|
|
|
|
+ "形态 [" + e.getKey() + "] 应写成 " + e.getValue());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_mapsApprovedToApproved() {
|
|
|
|
|
+ stubOneRow();
|
|
|
|
|
+
|
|
|
|
|
+ service.handleOnboardingNotify(signed(onboardingBody("1")), "{}");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("APPROVED", row.getReviewStatus(),
|
|
|
|
|
+ "本地列写的是 LsyMerchantReviewStatus.APPROVED.getValue()");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_mapsRejectedToRejected() {
|
|
|
|
|
+ stubOneRow();
|
|
|
|
|
+
|
|
|
|
|
+ service.handleOnboardingNotify(signed(onboardingBody("2")), "{}");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("REJECTED", row.getReviewStatus(),
|
|
|
|
|
+ "本地列写的是 LsyMerchantReviewStatus.REJECTED.getValue()");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 未知值 ⇒ <b>不抛、不瞎映射、原样记日志、应答 SUCCESS</b>。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>与「找不到行 ⇒ FAIL」<b>朝向相反,但两条都对</b>:区别是「这次通知该不该再来一次」。
|
|
|
|
|
+ * 未知值说明上游<u>确实</u>送到了有效地址、报文也是真的,只是我们还不认识这个值 ——
|
|
|
|
|
+ * 应答 FAIL 会让上游无限重推,而重推一万次也变不出一个我们能认的值。
|
|
|
|
|
+ * 那条日志是将来补值域的唯一线索,所以必须带**原值**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 断言「一条 SQL 都没发」是这条的另一半:值都不认识,就没有可写的目标状态。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_unknownStatus_stillSucceedsAndLogsRaw() {
|
|
|
|
|
+ String unknown = "9"; // 另一套值域(page 196236567)里的「审核驳回」—— 本套不认它
|
|
|
|
|
+
|
|
|
|
|
+ List<ILoggingEvent> captured = new ArrayList<>();
|
|
|
|
|
+ String response = withLogCapture(captured, () ->
|
|
|
|
|
+ service.handleOnboardingNotify(signed(onboardingBody(unknown)), "{}"));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(OK, response, "未知值必须应答 SUCCESS,防上游无限重推;实测: " + response);
|
|
|
|
|
+ assertTrue(messages(captured).stream().anyMatch(m -> m.contains(unknown)),
|
|
|
|
|
+ "未知值必须**原样**(" + unknown + ")进日志 —— 那是将来补值域的唯一线索。实测日志: "
|
|
|
|
|
+ + messages(captured));
|
|
|
|
|
+ verify(merchantMapper, never()).selectList(any());
|
|
|
|
|
+ verify(merchantMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────────────── 定位 ─────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★★ 入站键名 ≠ 本地列名:入站读的是 {@code merchant_no},本地列是 {@code mch_no}。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>本用例在报文里放了一个**诱饵** {@code mch_no}(列名那个写法)并给了不同的值,
|
|
|
|
|
+ * 然后断言查询绑定的谓词值是 {@code merchant_no} 那个值。
|
|
|
|
|
+ * 写成 {@code body.get("mch_no")} 时会去查诱饵 ⇒ 本用例红(真机上症状是
|
|
|
|
|
+ * 「每一条通知都走行不存在」)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_locatesByTheInboundKeyMerchantNo() {
|
|
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> body = onboardingBody("1");
|
|
|
|
|
+ body.put("mch_no", "DECOY-列名那个写法"); // 诱饵:这个值必须**不**被采用
|
|
|
|
|
+
|
|
|
|
|
+ service.handleOnboardingNotify(signed(body), "{}");
|
|
|
|
|
+
|
|
|
|
|
+ AbstractWrapper<?, ?, ?> w = capturedSelectList();
|
|
|
|
|
+ assertEquals(MCH_NO, boundPredicateValue(w, "mch_no"),
|
|
|
|
|
+ "定位谓词必须绑 merchant_no 那个值,实测: " + w.getSqlSegment()
|
|
|
|
|
+ + " / " + w.getParamNameValuePairs());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 行不存在 ⇒ 记 ERROR + 应答 FAIL(让上游重推)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>裁定:这条**不**学交易回调那条「查不到也应答成功、避免无限重推」——
|
|
|
|
|
+ * 交易回调查不到是「报文与本地单据对不上」的下游兜底场景,而这里查不到就是
|
|
|
|
|
+ * <b>我们这边还没建这一行</b>(或商户号给错了),正是最该让上游再推一次的情况。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_missingMerchantRow_returnsFail() {
|
|
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ String response = service.handleOnboardingNotify(signed(onboardingBody("1")), "{}");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(BAD_NOT_FOUND, response, "行不存在必须应答 FAIL,实测: " + response);
|
|
|
|
|
+ verify(merchantMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★★ {@code mch_no} 命中多行 ⇒ 记 ERROR + 应答 FAIL。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>这不是「理论上可能重复」</b>:2026-09-23 在真库上实测过 ——
|
|
|
|
|
+ * {@code mch_no='Y52211090004'} 有 2 行({@code id} 1 与 2102313081860202497),
|
|
|
|
|
+ * 且两行**都合法**(一行 {@code enterprise_id} 有值、一行 NULL,分别落在
|
|
|
|
|
+ * {@code uk_lsy_merchant_tenant_ent} 与 {@code uk_lsy_merchant_tenant_level} 上)。
|
|
|
|
|
+ * 唯一索引 {@code uk_lsy_merchant_mch_no} **今天不存在**(建不起来,见
|
|
|
|
|
+ * {@code db/lsy-merchant-mch-no-uk.sql})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>所以免登录入口在**没有索引**时靠的是**查询形状**:{@code selectList} 之后自己判,
|
|
|
|
|
+ * 多行一律不写。写成 {@code selectOne} + {@code LIMIT 1} 会**静默取任意一行** ——
|
|
|
|
|
+ * 无认证查询不带租户谓词,取到的那一行可能是**别的租户**的。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 断言为什么不只写 {@code assertEquals(FAIL, ...)}:那样「多行」与「不存在」两件事
|
|
|
|
|
+ * 回同一个串,把定位换成 {@code selectOne}(多行闸没了、退化成「不存在」)**照样绿**。
|
|
|
|
|
+ * 所以这里钉的是**回的是哪一条**,并用 {@code never().selectOne} 把形状也钉住。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_duplicateMchNo_returnsFail() {
|
|
|
|
|
+ LsyMerchantEntity other = new LsyMerchantEntity();
|
|
|
|
|
+ other.setId(1L);
|
|
|
|
|
+ other.setTenantId(2L);
|
|
|
|
|
+ other.setMchNo(MCH_NO);
|
|
|
|
|
+ other.setReviewStatus("PENDING");
|
|
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of(row, other));
|
|
|
|
|
+
|
|
|
|
|
+ String response = service.handleOnboardingNotify(signed(onboardingBody("1")), "{}");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(BAD_AMBIGUOUS, response,
|
|
|
|
|
+ "命中多行必须应答**多行**那一条 FAIL(不是「不存在」那一条),实测: " + response);
|
|
|
|
|
+ verify(merchantMapper, never()).update(any(), any());
|
|
|
|
|
+ verify(merchantMapper, never()).selectOne(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────────── 幂等 / 写入口 ─────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 同一通知重放两次,第二次是 <b>no-op</b>:值与库里相同则不写。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ {@code update} 被 {@code thenAnswer} 认领后会**真的**把新值写回那一行
|
|
|
|
|
+ * (模拟持久化)—— 所以第二次调用看到的已经是 {@code APPROVED}。
|
|
|
|
|
+ * 不做这一步,「重放」在 mock 里根本不存在:第二次仍会写库,而用例看起来照样绿。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_isIdempotent() {
|
|
|
|
|
+ stubOneRow();
|
|
|
|
|
+ Map<String, Object> body = signed(onboardingBody("审核通过"));
|
|
|
|
|
+
|
|
|
|
|
+ String first = service.handleOnboardingNotify(body, "{}");
|
|
|
|
|
+ String second = service.handleOnboardingNotify(body, "{}");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(OK, first);
|
|
|
|
|
+ assertEquals(OK, second, "重放也必须应答 SUCCESS(重推不该被当成错误)");
|
|
|
|
|
+ verify(merchantMapper, times(1)).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 不得复用 {@code LsyMerchantService.save} —— 它的编辑分支写不了
|
|
|
|
|
+ * {@code review_status} / {@code status},返回值又与正常成功无法区分
|
|
|
|
|
+ * (回读行里那两列仍是库里的旧值)。这条契约在 {@code save} 的 javadoc 里已经写着。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_doesNotWriteThroughSave() {
|
|
|
|
|
+ stubOneRow();
|
|
|
|
|
+
|
|
|
|
|
+ service.handleOnboardingNotify(signed(onboardingBody("1")), "{}");
|
|
|
|
|
+
|
|
|
|
|
+ verify(merchantService, never()).save(any());
|
|
|
|
|
+ verify(merchantMapper, never()).updateById(any());
|
|
|
|
|
+ verify(merchantMapper, never()).insert(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────────────── 日志安全 ─────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 报文里的 {@code \r\n} 不能伪造日志行(CWE-117)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>本入口免登录,且这些实参在**验签之前**就打(验签失败恰恰是最需要看「上游到底发了什么」
|
|
|
|
|
+ * 的时候,不能把日志推到验签之后)⇒ 报文字段是彻底不可信的输入。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 先断言「确实捕获到了事件」:logger 挂错时下面那条「不含换行」的断言会在**空列表**
|
|
|
|
|
+ * 上恒真 —— 静默绿,失效方式与它要防的缺陷一样隐蔽。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void onboardingNotify_sanitizesLogInput() {
|
|
|
|
|
+ String forged = "\r\n2026-01-01 00:00:00 ERROR 伪造的日志行";
|
|
|
|
|
+
|
|
|
|
|
+ List<ILoggingEvent> captured = new ArrayList<>();
|
|
|
|
|
+ withLogCapture(captured, () -> {
|
|
|
|
|
+ // 阶段 1:未验签(签名被伪造)⇒ merchant_no / reviewed_status / rawBody 三处实参
|
|
|
|
|
+ Map<String, Object> unsigned = onboardingBody("1" + forged);
|
|
|
|
|
+ unsigned.put("merchant_no", MCH_NO + forged);
|
|
|
|
|
+ unsigned.put("sign", "00000000000000000000000000000000");
|
|
|
|
|
+ service.handleOnboardingNotify(unsigned, "{\"result_code\":\"SUCCESS\"}" + forged);
|
|
|
|
|
+
|
|
|
|
|
+ // 阶段 2:已验签但值域未知 ⇒ 另一条日志语句(在另一条分支上,不能互相替代)。
|
|
|
|
|
+ // ★ 这里**不**给 selectList 打桩:未知值那条分支必须在**碰库之前**就返回 OK
|
|
|
|
|
+ // (见 onboardingNotify_unknownStatus_stillSucceedsAndLogsRaw 的 never() 断言)。
|
|
|
|
|
+ // 打了桩反而是一条永不消费的 stubbing,严格模式下会把本用例判红。
|
|
|
|
|
+ Map<String, Object> unknownStatus = onboardingBody("9" + forged);
|
|
|
|
|
+ unknownStatus.put("merchant_no", MCH_NO + forged);
|
|
|
|
|
+ service.handleOnboardingNotify(signed(unknownStatus), "{}" + forged);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(captured.isEmpty(),
|
|
|
|
|
+ "没有捕获到任何日志事件 —— appender 很可能挂错了 logger,本用例已失效");
|
|
|
|
|
+ List<String> dirty = messages(captured).stream()
|
|
|
|
|
+ .filter(m -> m.contains("\n") || m.contains("\r"))
|
|
|
|
|
+ .toList();
|
|
|
|
|
+ assertTrue(dirty.isEmpty(),
|
|
|
|
|
+ "日志里出现行终止符 ⇒ 未验签报文可伪造日志行(CWE-117);实际=" + dirty);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────────────── 夹具 ─────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 上游 page 196236556 的一份请求(未签名)。
|
|
|
|
|
+ * 字段名逐个取自该页的**请求参数表**;例外的只有 {@code reviewed_reason}:
|
|
|
|
|
+ * 参数表标它**必填=是**,而同页请求示例给的是 {@code null} —— 两种都见过,这里给值。
|
|
|
|
|
+ */
|
|
|
|
|
+ private Map<String, Object> onboardingBody(String reviewedStatus) {
|
|
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
|
|
+ m.put("inst_no", "900024");
|
|
|
|
|
+ m.put("merchant_no", MCH_NO);
|
|
|
|
|
+ m.put("merchant_name", "super超市");
|
|
|
|
|
+ m.put("reviewed_status", reviewedStatus);
|
|
|
|
|
+ m.put("reviewed_reason", "资料缺失");
|
|
|
|
|
+ m.put("channel_name", "某通道");
|
|
|
|
|
+ m.put("mch_id", "3025412121211");
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> signed(Map<String, Object> body) {
|
|
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>(body);
|
|
|
|
|
+ m.put("sign", signer.sign(m, KEY));
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 定位到**恰好一行**,并把 {@code update} 接成「真的写回那一行」——
|
|
|
|
|
+ * 写回的值从 wrapper 的 SET 子句里读,不是从入参猜的,
|
|
|
|
|
+ * 所以「SET 里写的不是 review_status」也会被这一步反映出来。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void stubOneRow() {
|
|
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of(row));
|
|
|
|
|
+ // ★ 必须用 doAnswer(...).when(mock).update(...) 这个形式,不能写
|
|
|
|
|
+ // when(mock.update(any(), any())).thenAnswer(...):
|
|
|
|
|
+ // when(...) 那种写法**会真的调用一次** mock 的方法,于是**上一轮**注册的 Answer
|
|
|
|
|
+ // 会在这行里被执行,而此刻两个实参都是 null(匹配器传的就是 null)
|
|
|
|
|
+ // ⇒ setValue(null, ...) 直接 NPE。本轮之前这里是「每个用例只打一次桩」,
|
|
|
|
|
+ // 在 acceptsBothStatusShapes 这种**循环里反复打桩**的用例上就会炸。
|
|
|
|
|
+ doAnswer(inv -> {
|
|
|
|
|
+ row.setReviewStatus((String) setValue(inv.getArgument(1), "review_status"));
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }).when(merchantMapper).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private AbstractWrapper<?, ?, ?> capturedSelectList() {
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
|
|
|
|
+ verify(merchantMapper).selectList(captor.capture());
|
|
|
|
|
+ return (AbstractWrapper<?, ?, ?>) captor.getValue();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 解析 `列 = ?` 谓词绑定的**值**(MP 只把占位符写进 SQL 文本,真值在参数表里)。 */
|
|
|
|
|
+ private static Object boundPredicateValue(AbstractWrapper<?, ?, ?> w, String column) {
|
|
|
|
|
+ Matcher m = Pattern.compile(column + " = #\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
|
|
|
|
|
+ .matcher(w.getSqlSegment());
|
|
|
|
|
+ assertTrue(m.find(), "WHERE 里应有 " + column + " = ? 谓词,实测: " + w.getSqlSegment());
|
|
|
|
|
+ return w.getParamNameValuePairs().get(m.group(1));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 从 `LambdaUpdateWrapper` 的 SET 子句里取某一列的值。 */
|
|
|
|
|
+ private static Object setValue(Object wrapper, String column) {
|
|
|
|
|
+ LambdaUpdateWrapper<?> w = (LambdaUpdateWrapper<?>) wrapper;
|
|
|
|
|
+ Matcher m = Pattern.compile(column + "=#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
|
|
|
|
|
+ .matcher(w.getSqlSet());
|
|
|
|
|
+ assertTrue(m.find(), "SET 里应有 " + column + ",实测: " + w.getSqlSet());
|
|
|
|
|
+ return w.getParamNameValuePairs().get(m.group(1));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String json(Map<String, Object> body) {
|
|
|
|
|
+ return JSONUtil.toJsonStr(body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static List<String> messages(List<ILoggingEvent> events) {
|
|
|
|
|
+ return events.stream().map(ILoggingEvent::getFormattedMessage).toList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 挂 appender、跑 body、**无论成败都摘掉**(不摘会把后续用例的日志也吃进来)。 */
|
|
|
|
|
+ private static <T> T withLogCapture(List<ILoggingEvent> sink, Supplier<T> body) {
|
|
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(LsyOnboardingNotifyService.class);
|
|
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
|
|
|
|
+ appender.start();
|
|
|
|
|
+ logger.addAppender(appender);
|
|
|
|
|
+ try {
|
|
|
|
|
+ T result = body.get();
|
|
|
|
|
+ sink.addAll(appender.list);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ logger.detachAppender(appender);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|