|
@@ -105,7 +105,7 @@ class LeshouyingOnboardingControllerTest {
|
|
|
|
|
|
|
|
private static final String MCH_NO = "Y52211090004";
|
|
private static final String MCH_NO = "Y52211090004";
|
|
|
|
|
|
|
|
- /** 六个端点共用的路径前缀(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)。 */
|
|
|
|
|
|
|
+ /** 七个端点共用的路径前缀(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)。 */
|
|
|
private static final String PREFIX = "/payment/collection/onboarding";
|
|
private static final String PREFIX = "/payment/collection/onboarding";
|
|
|
|
|
|
|
|
/** 省市区只读端点的完整路径(加记 29 §二 裁定 R-T9a 新增的第四个端点)。 */
|
|
/** 省市区只读端点的完整路径(加记 29 §二 裁定 R-T9a 新增的第四个端点)。 */
|
|
@@ -114,6 +114,9 @@ class LeshouyingOnboardingControllerTest {
|
|
|
/** 行业类目只读端点的完整路径(加记 35 §二 裁定 R-T9f′ 新增的第六个端点)。 */
|
|
/** 行业类目只读端点的完整路径(加记 35 §二 裁定 R-T9f′ 新增的第六个端点)。 */
|
|
|
private static final String MCC_PATH = PREFIX + "/mcc";
|
|
private static final String MCC_PATH = PREFIX + "/mcc";
|
|
|
|
|
|
|
|
|
|
+ /** 更新商户端点的完整路径(R-7c 开口后新增的第七个端点)。 */
|
|
|
|
|
+ private static final String UPDATE_PATH = PREFIX + "/update";
|
|
|
|
|
+
|
|
|
// ---- 资质图片上传端点(加记 33)用的夹具 ----
|
|
// ---- 资质图片上传端点(加记 33)用的夹具 ----
|
|
|
|
|
|
|
|
/** 上传端点的完整路径。 */
|
|
/** 上传端点的完整路径。 */
|
|
@@ -467,6 +470,160 @@ class LeshouyingOnboardingControllerTest {
|
|
|
verify(onboardingService, times(2)).createMerchant(any());
|
|
verify(onboardingService, times(2)).createMerchant(any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ──────────────── 更新商户端点(R-7c 开口 + 同一处 mass-assignment 封盖) ────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★★ 更新腿的租户闸:**先证明这个商户号属于本租户,才允许改它**,且闸排在改动之前。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>为什么这条腿比 {@code /commit} 更要紧:{@code /commit} 是「提交审核」(可重复、可再提交,
|
|
|
|
|
+ * 错一次还能再提交一次),而 {@code /update} 直接改上游商户的结算卡号 / 法人证件 / 资质图片 ——
|
|
|
|
|
+ * 拿别人的商户号调它,就是改别人的结算账户。所以闸的位置与 {@code /commit} 逐字同形:
|
|
|
|
|
+ * {@code requireTenantId()} 之后**立刻** {@code requireByMchNoForTenant}。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>两臂:
|
|
|
|
|
+ * <ol>
|
|
|
|
|
+ * <li>查询自带 {@code tenant_id = ?} 且绑定的就是上下文里的租户 —— 主体取**超管**
|
|
|
|
|
+ * ({@code TenantInnerInterceptor.ignoreTable} 的第一个分支,构造上不追加谓词)
|
|
|
|
|
+ * ⇒ 谓词只能由**被调方**自己构造,与拦截器在不在无关(同 {@code /commit} 那条用例);</li>
|
|
|
|
|
+ * <li>{@code InOrder} 钉住闸的查询排在 {@code updateMerchant}(上游调用)之前 ——
|
|
|
|
|
+ * 顺序反了**不是**「同样安全」:那等于拿一个还没证明属于本租户的商户号去改上游数据,
|
|
|
|
|
+ * 404/403 只是发晚了。</li>
|
|
|
|
|
+ * </ol>
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void updateEndpoint_gatesTenantBeforeTouchingUpstream() {
|
|
|
|
|
+ LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
|
|
|
|
|
+ LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
|
|
|
|
|
+ login(TENANT, true);
|
|
|
|
|
+ when(mapper.selectList(any())).thenReturn(List.of(row(MCH_NO)));
|
|
|
|
|
+
|
|
|
|
|
+ c.update(updateBody(MCH_NO));
|
|
|
|
|
+
|
|
|
|
|
+ AbstractWrapper<?, ?, ?> w = capturedSelectList(mapper);
|
|
|
|
|
+ assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
|
|
|
|
|
+ "查询必须自带 tenant_id 谓词(超管主体下拦截器不会追加)。实测: " + w.getSqlSegment());
|
|
|
|
|
+ assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
|
|
|
|
|
+ "谓词占位符必须绑定上下文里的租户 " + TENANT + "。实测: " + w.getParamNameValuePairs());
|
|
|
|
|
+
|
|
|
|
|
+ InOrder gateFirst = inOrder(mapper, onboardingService);
|
|
|
|
|
+ gateFirst.verify(mapper).selectList(any());
|
|
|
|
|
+ gateFirst.verify(onboardingService).updateMerchant(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 商户号不属于当前租户 ⇒ **404,且一次上游都不碰**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>零接触读数是**无歧义**的:{@code onboardingService} 是本控制器里唯一能发出上游报文的入口,
|
|
|
|
|
+ * 而闸是它之前唯一那一步。断言的判据是**异常码**(404)而不是「查不到」——
|
|
|
|
|
+ * 后者在把租户校验整条删掉之后仍会绿({@code selectList} 返回什么完全由 mock 决定)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void updateEndpoint_foreignMchNo_is404_withoutTouchingUpstream() {
|
|
|
|
|
+ LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
|
|
|
|
|
+ LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ when(mapper.selectList(any())).thenReturn(List.of()); // 本租户下没有这个商户号
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
|
|
+ () -> c.update(updateBody(MCH_NO)),
|
|
|
|
|
+ "不属于本租户的商户号必须在触达上游之前被拒");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(404, ex.getCode(), "必须显式拒绝(404)。实测: " + ex.getCode());
|
|
|
|
|
+ verifyNoInteractions(onboardingService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 请求体里出现 {@code tenant_id} ⇒ **400(业务码,HTTP 200)**,不是 500「服务器内部错误」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>同创建腿那条:{@link LeshouyingOnboardingController.UpdateMerchantRequest#tenantId}
|
|
|
|
|
+ * 是**只为响亮拒绝**而声明的字段 —— 不声明它,本仓 {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true,
|
|
|
|
|
+ * 这个多出来的键会变成一份 {@code HttpMessageNotReadableException},而
|
|
|
|
|
+ * {@code GlobalExceptionHandler} 五个分支里没有它 ⇒ 落兜底 500。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 拒绝的是**这一项**,不是整份报文:正对照紧跟其后(同形报文、去掉该键必须被受理),
|
|
|
|
|
+ * 否则那个 400 也可能只是「整份报文压根没绑上」造成的。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void updateEndpoint_rejectsBodySuppliedTenantId() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+
|
|
|
|
|
+ // 反例:多一个 tenant_id ⇒ 拒绝
|
|
|
|
|
+ mockMvc.perform(post(UPDATE_PATH)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"mch_no\":\"" + MCH_NO + "\",\"tenant_id\":" + BODY_TENANT + "}"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
|
|
+
|
|
|
|
|
+ // 被拒的那一刻:这次更新一次都没发生,且请求体里那个租户号一次都没被当作租户用过
|
|
|
|
|
+ verify(onboardingService, never()).updateMerchant(any());
|
|
|
|
|
+ verify(merchantService, never()).requireByMchNoForTenant(any(), eq(BODY_TENANT));
|
|
|
|
|
+
|
|
|
|
|
+ // 正对照:同形报文、去掉 tenant_id ⇒ 受理
|
|
|
|
|
+ mockMvc.perform(post(UPDATE_PATH)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"mch_no\":\"" + MCH_NO + "\"}"))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ verify(onboardingService, times(1)).updateMerchant(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★★ <b>R-7c 的封盖</b>:请求体里带了**非空**费率模板 ⇒ 响亮拒绝,且**不得触达上游**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么更新腿也必须有这道闸】台账 R-7c 逐字:「{@code LsyMerchantUpdateDTO.rateCode}
|
|
|
|
|
+ * + {@code LsyOnboardingService:500} 是同一 mass-assignment 的更新腿 …… 将来开 HTTP 入口时
|
|
|
|
|
+ * **需同样封盖**」。本端点就是那扇门。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>它和创建腿的攻击形态**不同、但同源**:创建腿是「DTO 有值则覆盖平台配置」(新商户的费率),
|
|
|
|
|
+ * 而 {@code updateMerchant} 是**增量**更新({@code putIfNotBlank})⇒ 报文里给一个非空的
|
|
|
|
|
+ * {@code rate_code} 就是把**这个商户谈好的费率**改成任意值;而这条腿**有意不回落配置**
|
|
|
|
|
+ * ({@code LsyOnboardingService:498-499})⇒ 覆盖它不需要绕过任何回落逻辑,一个键就够了。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 判据与创建腿**同一个**({@code StrUtil.isNotBlank}),理由在两条腿上一致:
|
|
|
|
|
+ * 空白值被服务层的 {@code putIfNotBlank} 直接丢掉 ⇒ 报文里根本不会出现 {@code rate_code}
|
|
|
|
|
+ * ⇒ 它改不动费率,不是攻击形态;拒掉它只会把一次正常更新变成失败。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ <b>本用例**不**动 {@code LsyOnboardingService:498-499}</b>:那是「有意不回落配置」,
|
|
|
|
|
+ * 是这条腿的正确行为,不是要顺带「修掉」的 bug。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void updateEndpoint_rejectsBodySuppliedRateCode_beforeTouchingUpstream() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+
|
|
|
|
|
+ // 反例:费率模板由请求体给 ⇒ 400(文案用 LABELS 里的中文标签「费率模板」,不带上游键名)
|
|
|
|
|
+ mockMvc.perform(post(UPDATE_PATH)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"mch_no\":\"" + MCH_NO + "\",\"rate_code\":\"RT0001\"}"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
|
|
+ .andExpect(jsonPath("$.msg", containsString("费率模板")));
|
|
|
|
|
+ verifyNoInteractions(onboardingService);
|
|
|
|
|
+
|
|
|
|
|
+ // 正对照:同形报文、去掉 rate_code ⇒ 受理
|
|
|
|
|
+ mockMvc.perform(post(UPDATE_PATH)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"mch_no\":\"" + MCH_NO + "\"}"))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ verify(onboardingService).updateMerchant(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 口径:**空串 / 纯空白不拒** —— 与 {@link #createEndpoint_allowsBlankRateCode} 同一条判据
|
|
|
|
|
+ * (服务层 {@code putIfNotBlank} 的补集),不该因为换了条腿就分叉成两套。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void updateEndpoint_allowsBlankRateCode() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+
|
|
|
|
|
+ for (String blank : List.of("", " ")) {
|
|
|
|
|
+ mockMvc.perform(post(UPDATE_PATH)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"mch_no\":\"" + MCH_NO + "\",\"rate_code\":\"" + blank + "\"}"))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ verify(onboardingService, times(2)).updateMerchant(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
|
|
* 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
|
|
|
*
|
|
*
|
|
@@ -1552,9 +1709,9 @@ class LeshouyingOnboardingControllerTest {
|
|
|
LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
|
|
|
|
|
|
List<Method> mapped = mappedMethods();
|
|
List<Method> mapped = mappedMethods();
|
|
|
- assertEquals(6, mapped.size(),
|
|
|
|
|
- "本控制器应恰好六个端点(三个权限串 → 六个端点:/areas、/upload-image、/mcc 都复用 :create,"
|
|
|
|
|
- + "加记 29 §二 裁定 R-T9a、加记 33、加记 35 §二 裁定 R-T9f′)。实测: "
|
|
|
|
|
|
|
+ assertEquals(7, mapped.size(),
|
|
|
|
|
+ "本控制器应恰好七个端点(三个权限串 → 七个端点:/areas、/upload-image、/mcc、/update 都复用 :create,"
|
|
|
|
|
+ + "加记 29 §二 裁定 R-T9a、加记 33、加记 35 §二 裁定 R-T9f′、R-7c)。实测: "
|
|
|
+ mapped.stream().map(Method::getName).toList());
|
|
+ mapped.stream().map(Method::getName).toList());
|
|
|
|
|
|
|
|
Set<String> onAnnotations = new LinkedHashSet<>();
|
|
Set<String> onAnnotations = new LinkedHashSet<>();
|
|
@@ -1817,6 +1974,7 @@ class LeshouyingOnboardingControllerTest {
|
|
|
() -> controller.page(1, 20, null),
|
|
() -> controller.page(1, 20, null),
|
|
|
() -> controller.create(createBody("E1", "示例商户")),
|
|
() -> controller.create(createBody("E1", "示例商户")),
|
|
|
() -> controller.commit(commitBody(MCH_NO)),
|
|
() -> controller.commit(commitBody(MCH_NO)),
|
|
|
|
|
+ () -> controller.update(updateBody(MCH_NO)),
|
|
|
() -> controller.areas("1", null),
|
|
() -> controller.areas("1", null),
|
|
|
() -> controller.mcc("1", null, null),
|
|
() -> controller.mcc("1", null, null),
|
|
|
() -> controller.uploadImage(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)));
|
|
() -> controller.uploadImage(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)));
|
|
@@ -1873,6 +2031,20 @@ class LeshouyingOnboardingControllerTest {
|
|
|
props.setOnboardingNotifyUrl("https://example.com/xjz/notify/lsy/onboarding");
|
|
props.setOnboardingNotifyUrl("https://example.com/xjz/notify/lsy/onboarding");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 更新报文的 DTO 夹具 —— **只给 {@code mchNo}**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>为什么其余字段一个都不填:{@code updateMerchant} 是**增量**语义({@code putIfNotBlank}),
|
|
|
|
|
+ * 只给 {@code mchNo} 就等于「本次不改任何一项」——这正是这条腿最小的合法报文,
|
|
|
|
|
+ * 也让「闸有没有过」这件事不被别的字段干扰。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static LeshouyingOnboardingController.UpdateMerchantRequest updateBody(String mchNo) {
|
|
|
|
|
+ LeshouyingOnboardingController.UpdateMerchantRequest b =
|
|
|
|
|
+ new LeshouyingOnboardingController.UpdateMerchantRequest();
|
|
|
|
|
+ b.setMchNo(mchNo);
|
|
|
|
|
+ return b;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private static LsyCommitMerchantInfoDTO commitBody(String mchNo) {
|
|
private static LsyCommitMerchantInfoDTO commitBody(String mchNo) {
|
|
|
LsyCommitMerchantInfoDTO d = new LsyCommitMerchantInfoDTO();
|
|
LsyCommitMerchantInfoDTO d = new LsyCommitMerchantInfoDTO();
|
|
|
d.setMchNo(mchNo);
|
|
d.setMchNo(mchNo);
|