|
|
@@ -0,0 +1,534 @@
|
|
|
+package com.payment.platform.module.payment.leshouying.controller;
|
|
|
+
|
|
|
+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.metadata.TableInfoHelper;
|
|
|
+import com.fasterxml.jackson.databind.JavaType;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.payment.platform.common.config.JacksonConfig;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.common.exception.GlobalExceptionHandler;
|
|
|
+import com.payment.platform.common.response.PageResult;
|
|
|
+import com.payment.platform.core.security.LoginUser;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyCommitMerchantInfoDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
+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.springframework.core.annotation.AnnotatedElementUtils;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
|
|
+import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
|
+import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.lang.reflect.ParameterizedType;
|
|
|
+import java.lang.reflect.Type;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collection;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.TreeSet;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 进件 HTTP 端点与租户归属闸(IDOR)。
|
|
|
+ *
|
|
|
+ * <p><b>【为什么本类里既有 mock 服务、又有一个真服务】</b>
|
|
|
+ * 有两个用例钉的是「**真的写了一行**」与「**查询真的带了租户谓词**」——
|
|
|
+ * 那两件事的载体是 {@link LsyMerchantService} 里的 wrapper 与实体,把服务 mock 掉就把它们一起
|
|
|
+ * mock 掉了({@code verify(merchantService).createOnboardingRow(...)} 只证明「调了这个方法」,
|
|
|
+ * 不证明「写进去的是 PENDING」)。那两个用例用 {@code new LsyMerchantService(mapperMock)}
|
|
|
+ * 显式装配一个真服务 —— 与 {@code LeshouyingOpenApiErrorContractTest} 里
|
|
|
+ * {@code new LsyQueryDetailAssembler(...)} 同一条理由。
|
|
|
+ *
|
|
|
+ * <p><b>【为什么未知字段的用例必须走 MockMvc】</b>请求体到实体的绑定发生在**进入方法体之前**;
|
|
|
+ * 直调 {@code controller.create(dto)} 根本不经过 Jackson,也就钉不住「请求体里的字段名长什么样」。
|
|
|
+ * 照 {@code LeshouyingOpenApiErrorContractTest} 的做法:真的 {@code RequestMappingHandlerAdapter}
|
|
|
+ * + **应用真实的 ObjectMapper**({@code JacksonConfig} 那个裸 {@code new ObjectMapper()})。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class LeshouyingOnboardingControllerTest {
|
|
|
+
|
|
|
+ /** 认证上下文里的租户 —— 就是那个「归属只许来自这里」的值。 */
|
|
|
+ private static final Long TENANT = 7L;
|
|
|
+
|
|
|
+ /** 故意与 {@link #TENANT} 不同的数:请求体里塞它,用来证明它没被采纳。 */
|
|
|
+ private static final long BODY_TENANT = 999L;
|
|
|
+
|
|
|
+ private static final String MCH_NO = "Y52211090004";
|
|
|
+
|
|
|
+ /** 三个端点共用的路径前缀(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)。 */
|
|
|
+ private static final String PREFIX = "/payment/collection/onboarding";
|
|
|
+
|
|
|
+ @Mock private LsyMerchantService merchantService;
|
|
|
+ @Mock private LsyOnboardingService onboardingService;
|
|
|
+
|
|
|
+ private LeshouyingOnboardingController controller;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ controller = new LeshouyingOnboardingController(merchantService, onboardingService);
|
|
|
+ ObjectMapper appMapper = new JacksonConfig().objectMapper();
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
|
|
+ .setControllerAdvice(new GlobalExceptionHandler())
|
|
|
+ .setMessageConverters(new MappingJackson2HttpMessageConverter(appMapper))
|
|
|
+ .build();
|
|
|
+ // LambdaQueryWrapper 的元数据缓存(无 Spring 上下文时必需),同 LsyMerchantServiceTest。
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
|
|
+ LsyMerchantEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @AfterEach
|
|
|
+ void clearContext() {
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 端点:归属只来自认证上下文 ─────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ {@code tenantId} 只来自认证上下文:上下文里是 {@link #TENANT},落库用的就必须是它。
|
|
|
+ * 与 {@link #createEndpoint_rejectsBodySuppliedTenantId} 是**一对** ——
|
|
|
+ * 只钉「不接受请求体」而不钉「必须取上下文」,把 tenantId 恒写成 null 也能绿。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createEndpoint_usesTenantFromSecurityContext() {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
|
|
|
+ when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
|
|
|
+
|
|
|
+ controller.create(createBody("E1", "示例商户"));
|
|
|
+
|
|
|
+ verify(merchantService).createOnboardingRow(eq(TENANT), any(), eq(MCH_NO), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 载荷级的活:建号成功后**真的落了一行**,且 {@code review_status} 是 {@code PENDING}。
|
|
|
+ *
|
|
|
+ * <p>不落库的话 Task 6 的审核通知腿(按 {@code mch_no} 定位本地行)**永远**走「找不到行」,
|
|
|
+ * 上游的审核结果写不进任何状态;落到建表语句默认的 {@code APPROVED} 则等于把「还没审核」
|
|
|
+ * 读成「审核过了」。
|
|
|
+ *
|
|
|
+ * <p>断言写在 {@code merchantMapper.insert} 的**实参**上(真服务 + mock mapper),
|
|
|
+ * 不是断言「调用了某个方法」—— 见类注释。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createEndpoint_persistsRowWithPendingStatus() {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
|
|
|
+ LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
|
|
|
+ when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
|
|
|
+
|
|
|
+ c.create(createBody("E1", "示例商户"));
|
|
|
+
|
|
|
+ ArgumentCaptor<LsyMerchantEntity> captor = ArgumentCaptor.forClass(LsyMerchantEntity.class);
|
|
|
+ verify(mapper).insert(captor.capture());
|
|
|
+ LsyMerchantEntity inserted = captor.getValue();
|
|
|
+
|
|
|
+ assertEquals("PENDING", inserted.getReviewStatus(),
|
|
|
+ "新行必须是待审核,不得落到建表语句的默认值 APPROVED。实测: " + inserted.getReviewStatus());
|
|
|
+ assertEquals(TENANT, inserted.getTenantId(), "tenant_id 只能来自认证上下文");
|
|
|
+ assertEquals("E1", inserted.getEnterpriseId(), "enterprise_id 来自请求体(业务字段)");
|
|
|
+ assertEquals(MCH_NO, inserted.getMchNo(), "mch_no 来自上游建号的返回值");
|
|
|
+ assertEquals("示例商户", inserted.getMerchantName());
|
|
|
+ assertNull(inserted.getId(), "主键由 MP 分配(ASSIGN_ID),调用方不得自带 id");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 反向:上游抛错时**不得**留下半个行。
|
|
|
+ *
|
|
|
+ * <p>一个没有商户号的行比「没有行」更坏 —— 它会让 Task 6 那条按 {@code mch_no} 定位的
|
|
|
+ * 通知腿命中一个永远收不到审核结果的壳。所以顺序必须是**先建号、后落行**。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createEndpoint_doesNotPersistRow_whenUpstreamThrows() {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
|
|
|
+ LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
|
|
|
+ when(onboardingService.createMerchant(any()))
|
|
|
+ .thenThrow(new BusinessException(502, "上游返回成功但没有可用的商户号"));
|
|
|
+
|
|
|
+ assertThrows(BusinessException.class, () -> c.create(createBody("E1", "示例商户")));
|
|
|
+
|
|
|
+ verify(mapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 请求体里塞 {@code tenant_id} ⇒ **响亮拒绝**,且一个服务都没碰。
|
|
|
+ *
|
|
|
+ * <p>同 {@code LeshouyingMerchantController.save} 的既有教训(那里是
|
|
|
+ * {@code entity.setTenantId(null)} —— 归属不由调用方指定)。本端点选**拒绝**而不是**忽略**:
|
|
|
+ * 忽略会让写错了这个字段的调用方拿到「成功」,并以为自己的指定生效了。
|
|
|
+ *
|
|
|
+ * <p>本用例自带**正对照**:不带 {@code tenant_id} 的同形报文必须被正常受理 ——
|
|
|
+ * 否则那个「400」可能只是因为整份报文压根没绑上。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createEndpoint_rejectsBodySuppliedTenantId() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
|
|
|
+ when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
|
|
|
+
|
|
|
+ // 正对照:同样的报文、去掉 tenant_id ⇒ 受理
|
|
|
+ mockMvc.perform(post(PREFIX + "/create")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\"}"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+
|
|
|
+ // 反例:多一个 tenant_id ⇒ 拒绝(业务码 400,HTTP 200 —— 本仓 BusinessException 的既有出口)
|
|
|
+ mockMvc.perform(post(PREFIX + "/create")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"enterprise_id\":\"E1\",\"tenant_id\":" + BODY_TENANT + "}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
+
|
|
|
+ verify(merchantService, never()).createOnboardingRow(eq(BODY_TENANT), any(), any(), any());
|
|
|
+ verify(merchantService, times(1)).createOnboardingRow(any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
|
|
|
+ *
|
|
|
+ * <p>三个端点一起钉:{@code page} 不带 {@code mchNo},但它同样是「以租户为范围」的读 ——
|
|
|
+ * 一个没有租户的主体在 {@code TenantInnerInterceptor} 下**不会被追加任何谓词**
|
|
|
+ * (Step 0 实测第 3 行),拿到的是全平台所有租户的商户映射。所以它也要拒。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void everyEndpoint_rejectsPrincipalWithoutTenant_loudly() {
|
|
|
+ login(null, false);
|
|
|
+ for (Runnable call : allThreeEndpoints()) {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class, call::run,
|
|
|
+ "已登录但 tenantId == null 的主体必须被拒(Step 0 表第 3 行)");
|
|
|
+ assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
+ for (Runnable call : allThreeEndpoints()) {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class, call::run,
|
|
|
+ "无认证上下文同样必须被拒(Step 0 表第 4 行)");
|
|
|
+ assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ verifyNoInteractions(merchantService, onboardingService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 闸不依赖拦截器 ─────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ 本任务唯一能证明「**闸不依赖拦截器**」的用例。
|
|
|
+ *
|
|
|
+ * <p>主体取**超管** —— 它是 {@code TenantInnerInterceptor.ignoreTable} 的**第一个**分支
|
|
|
+ * ({@code isSuperuser == true ⇒ return true}),构造上**必定**不追加租户谓词。
|
|
|
+ *
|
|
|
+ * <p>两臂:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>超管 + 无显式租户 ⇒ 抛 403</b>。断言的是**异常码**,不是「查不到」——
|
|
|
+ * 后者在把租户校验删掉之后仍会绿({@code selectList} 返回什么完全由 mock 决定)。</li>
|
|
|
+ * <li><b>超管 + 有显式租户 ⇒ 查询自带 {@code tenant_id = ?}</b>,且绑定的值就是传进去的那个。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>★ <b>本用例的诚实边界</b>:这是纯 Mockito 用例,**拦截器根本没有装配** ——
|
|
|
+ * 这正是要证明的:那条谓词由**被调方**构造,与拦截器在不在、返回什么无关。
|
|
|
+ * 拦截器自己的四行行为由 Step 0 的探针逐行实测(读数与量法在 {@code task-7-report.md}),
|
|
|
+ * 本用例不重复它、也不声称测过它。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void merchantQuery_carriesExplicitTenantPredicate_evenWhenInterceptorWouldSkip() {
|
|
|
+ LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
|
|
|
+ LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
|
|
|
+
|
|
|
+ // 臂 ①:超管 + 无显式租户 ⇒ 显式拒绝,且一行都没查
|
|
|
+ login(null, true);
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class, () -> c.commit(commitBody(MCH_NO)));
|
|
|
+ assertEquals(403, ex.getCode(),
|
|
|
+ "超管 + 无租户必须显式拒绝(403/400),不得「自然查不到」。实测: " + ex.getCode());
|
|
|
+ verifyNoInteractions(mapper);
|
|
|
+
|
|
|
+ // 臂 ②:同为主体超管,给出显式租户 ⇒ 查询仍自带 tenant_id 谓词
|
|
|
+ login(TENANT, true);
|
|
|
+ when(mapper.selectList(any())).thenReturn(List.of(row(MCH_NO)));
|
|
|
+ c.commit(commitBody(MCH_NO));
|
|
|
+
|
|
|
+ AbstractWrapper<?, ?, ?> w = capturedSelectList(mapper);
|
|
|
+ assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
|
|
|
+ "查询必须自带 tenant_id 谓词(超管主体下拦截器不会追加)。实测: " + w.getSqlSegment());
|
|
|
+ assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
|
|
|
+ "谓词占位符必须绑定上下文里的租户 " + TENANT + "。实测: " + w.getParamNameValuePairs());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 权限串与应答字段名(结构性) ─────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 三串权限常量本身。
|
|
|
+ *
|
|
|
+ * <p>权限匹配是**全等**({@code PermissionCheck.hasAny} 用 {@code authorities::contains}),
|
|
|
+ * 差一个字符即 403;而**超管直通**,所以任何用超管跑的用例都测不出写错的权限串。
|
|
|
+ * 本用例是那处独立核对:字面量写死在测试里,与代码里的常量、与注解上的值逐字比。
|
|
|
+ *
|
|
|
+ * <p>★ 注解里用**字面量**而不是常量拼接:{@code grep 'module_payment:leshouying:onboarding:create'}
|
|
|
+ * 必须能命中 {@code @PreAuthorize} 那一行(Task 8 的菜单 SQL 要按字面量逐字对齐)。
|
|
|
+ * 代价是同一串在文件里出现两次 —— 于是下面那圈反射断言把「注解值 == 常量」钉死。
|
|
|
+ *
|
|
|
+ * <p>★ 本用例**不读** {@code db/} 下的 SQL 文件(有意如此):单测读 SQL 是把文件当接口,
|
|
|
+ * 脆且假;「SQL 与代码逐字一致」由 Task 8 的只读核对语句在库上验。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void endpointPermissionStrings_matchTheDeclaredConstants() {
|
|
|
+ assertEquals("module_payment:leshouying:onboarding:list",
|
|
|
+ LeshouyingOnboardingController.PERM_ONBOARDING_LIST);
|
|
|
+ assertEquals("module_payment:leshouying:onboarding:create",
|
|
|
+ LeshouyingOnboardingController.PERM_ONBOARDING_CREATE);
|
|
|
+ assertEquals("module_payment:leshouying:onboarding:commit",
|
|
|
+ LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
|
+
|
|
|
+ Set<String> declared = Set.of(
|
|
|
+ LeshouyingOnboardingController.PERM_ONBOARDING_LIST,
|
|
|
+ LeshouyingOnboardingController.PERM_ONBOARDING_CREATE,
|
|
|
+ LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
|
+
|
|
|
+ List<Method> mapped = mappedMethods();
|
|
|
+ assertEquals(3, mapped.size(),
|
|
|
+ "本控制器应恰好三个端点(三个权限串 → 三个端点,裁定 2)。实测: "
|
|
|
+ + mapped.stream().map(Method::getName).toList());
|
|
|
+
|
|
|
+ Set<String> onAnnotations = new LinkedHashSet<>();
|
|
|
+ for (Method m : mapped) {
|
|
|
+ String value = annotationValue(m);
|
|
|
+ assertNotNull(value, m.getName() + " 缺 @PreAuthorize —— 端点会变成任何登录用户可调");
|
|
|
+ assertEquals(2, value.chars().filter(c -> c == '\'').count(),
|
|
|
+ m.getName() + " 的 @PreAuthorize 引号数不对(应为成对的两个): " + value);
|
|
|
+ String literal = value.substring(value.indexOf('\'') + 1, value.lastIndexOf('\''));
|
|
|
+ assertEquals("@perm.hasAny('" + literal + "')", value,
|
|
|
+ m.getName() + " 的 @PreAuthorize 不是 @perm.hasAny('…') 形态: " + value);
|
|
|
+ onAnnotations.add(literal);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(declared, onAnnotations,
|
|
|
+ "注解上出现的权限串必须**恰好**是声明的三串 —— 多一串就是自行新增了权限串"
|
|
|
+ + "(裁定 2:须报主控裁定,不得自增)。实测: " + onAnnotations);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 应答字段名白名单(Global Constraint 1:到达租户眼前的字符串不得带上游专有键名)。
|
|
|
+ *
|
|
|
+ * <p>判据(约束原文的两查):某个键名算「上游专有」,当且仅当
|
|
|
+ * ① 建表语句里没有这一列 **且** ② 有对外 DTO/实体把它回给租户。
|
|
|
+ * 于是白名单 = {@code Result} 信封 ∪ {@code PageResult} 分页壳 ∪ {@code LsyMerchantEntity}
|
|
|
+ * 的列名(都是**本地**词)。
|
|
|
+ *
|
|
|
+ * <p>两件事一起钉:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>结构性</b>:应答载荷类型不得是 {@code Map} / {@code Object} ——
|
|
|
+ * 那正是「把上游原始应答直接回给租户」的形状({@code LsyOnboardingService.merchantInfo}
|
|
|
+ * 返回的就是 {@code Map<String,Object>},里面有身份证号、法人证件号、银行卡照片 URL)。</li>
|
|
|
+ * <li><b>字段名</b>:把每个端点应答类型的属性名(按应用 ObjectMapper 的 SNAKE_CASE 展开)
|
|
|
+ * 收成一个集合,断言它是白名单的**子集**。只写「不含某几个键」是黑名单,
|
|
|
+ * 漏一个上游键它照样绿。</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void noEndpointReturnsUpstreamOnlyFields() {
|
|
|
+ ObjectMapper appMapper = new JacksonConfig().objectMapper();
|
|
|
+
|
|
|
+ Set<String> allowed = new LinkedHashSet<>(List.of(
|
|
|
+ // Result 信封
|
|
|
+ "code", "msg", "data", "status_code", "success",
|
|
|
+ // PageResult 分页壳
|
|
|
+ "page_no", "page_size", "total", "has_next", "list",
|
|
|
+ // pay_lsy_merchant 的列(V1.8 建表语句)
|
|
|
+ "id", "tenant_id", "enterprise_id", "mch_no", "shop_no", "merchant_name",
|
|
|
+ "review_status", "status", "description", "created_time", "updated_time"));
|
|
|
+
|
|
|
+ Set<String> seen = new TreeSet<>();
|
|
|
+ for (Method m : mappedMethods()) {
|
|
|
+ Type payload = payloadType(m);
|
|
|
+ Class<?> raw = rawClass(payload);
|
|
|
+ assertFalse(Map.class.isAssignableFrom(raw),
|
|
|
+ m.getName() + " 直接返回 Map —— 上游原始应答(含 PII)不得回给租户。实测: " + raw);
|
|
|
+ assertNotEquals(Object.class, raw, m.getName() + " 的载荷类型是 Object(同上)");
|
|
|
+ // ★ 收的是**整个**泛型返回类型(Result<…> 连同里面的 PageResult 壳与元素),
|
|
|
+ // 不是剥完信封那一层 —— 剥掉的那两层(信封 / 分页壳)同样会进应答体。
|
|
|
+ seen.addAll(propertyNames(appMapper,
|
|
|
+ appMapper.getTypeFactory().constructType(m.getGenericReturnType())));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 正对照:反射这条链要么收全了、要么什么都没收到 —— 空集合会让「子集」恒真
|
|
|
+ assertTrue(seen.contains("mch_no") && seen.contains("review_status") && seen.contains("list"),
|
|
|
+ "应答字段名没收全,白名单断言已失效。实测: " + seen);
|
|
|
+ assertTrue(seen.size() >= 15, "收集到的字段名太少(" + seen.size() + ")。实测: " + seen);
|
|
|
+
|
|
|
+ Set<String> extra = new TreeSet<>(seen);
|
|
|
+ extra.removeAll(allowed);
|
|
|
+ assertEquals(Set.of(), extra,
|
|
|
+ "应答里出现了本地词表之外的字段名(Global Constraint 1)。实测多出: " + extra);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────────── 反射工具 ───────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 本控制器自己声明的、带请求映射的方法。
|
|
|
+ * ★ **不以「有没有 {@code @PreAuthorize}」为筛选条件** —— 否则漏加注解的方法会被静默排除,
|
|
|
+ * 而「每个端点都必须有权限注解」正是要钉的东西。
|
|
|
+ */
|
|
|
+ private static List<Method> mappedMethods() {
|
|
|
+ List<Method> out = new ArrayList<>();
|
|
|
+ for (Method m : LeshouyingOnboardingController.class.getDeclaredMethods()) {
|
|
|
+ if (AnnotatedElementUtils.findMergedAnnotation(m, RequestMapping.class) != null) {
|
|
|
+ out.add(m);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ out.sort(java.util.Comparator.comparing(Method::getName));
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String annotationValue(Method m) {
|
|
|
+ PreAuthorize a = AnnotatedElementUtils.findMergedAnnotation(m, PreAuthorize.class);
|
|
|
+ return a == null ? null : a.value();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** {@code Result<X>} / {@code Result<PageResult<X>>} 里的 X(剥掉信封)。 */
|
|
|
+ private static Type payloadType(Method m) {
|
|
|
+ Type t = typeArgument(m.getGenericReturnType());
|
|
|
+ if (rawClass(t) == PageResult.class) {
|
|
|
+ t = typeArgument(t);
|
|
|
+ }
|
|
|
+ return t;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Type typeArgument(Type t) {
|
|
|
+ if (t instanceof ParameterizedType p && p.getActualTypeArguments().length == 1) {
|
|
|
+ return p.getActualTypeArguments()[0];
|
|
|
+ }
|
|
|
+ throw new IllegalStateException("应答类型不是单参数泛型,本工具解不了: " + t);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Class<?> rawClass(Type t) {
|
|
|
+ if (t instanceof Class<?> c) {
|
|
|
+ return c;
|
|
|
+ }
|
|
|
+ if (t instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) {
|
|
|
+ return c;
|
|
|
+ }
|
|
|
+ throw new IllegalStateException("解不出原始类型: " + t);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 用**应用真实的** ObjectMapper 展开属性名(SNAKE_CASE),并**递归下沉到嵌套的 bean**
|
|
|
+ * ({@code Result.data} 是 {@code PageResult},{@code PageResult.list} 的元素是实体 ——
|
|
|
+ * 这三层的键都会进应答体,少收一层就等于给那一层开了后门)。
|
|
|
+ * JDK 自带的标量类型(String / 数值 / 时间 / 枚举)不再下沉。
|
|
|
+ */
|
|
|
+ private static Set<String> propertyNames(ObjectMapper mapper, JavaType jt) {
|
|
|
+ return propertyNames(mapper, jt, 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Set<String> propertyNames(ObjectMapper mapper, JavaType jt, int depth) {
|
|
|
+ Set<String> out = new LinkedHashSet<>();
|
|
|
+ if (jt == null || depth > 4 || isJdkScalar(jt)) {
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ for (var p : mapper.getSerializationConfig().introspect(jt).findProperties()) {
|
|
|
+ out.add(p.getName());
|
|
|
+ JavaType pt = p.getPrimaryType();
|
|
|
+ if (pt == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (Collection.class.isAssignableFrom(pt.getRawClass()) && pt.getContentType() != null) {
|
|
|
+ out.addAll(propertyNames(mapper, pt.getContentType(), depth + 1));
|
|
|
+ } else {
|
|
|
+ out.addAll(propertyNames(mapper, pt, depth + 1));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isJdkScalar(JavaType jt) {
|
|
|
+ Class<?> c = jt.getRawClass();
|
|
|
+ if (c.isPrimitive() || c.isEnum() || c.isArray()) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ String n = c.getName();
|
|
|
+ return n.startsWith("java.") || n.startsWith("javax.") || n.startsWith("jakarta.");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────────── 夹具 ───────────────────────────────
|
|
|
+
|
|
|
+ private List<Runnable> allThreeEndpoints() {
|
|
|
+ return List.of(
|
|
|
+ () -> controller.page(1, 20, null),
|
|
|
+ () -> controller.create(createBody("E1", "示例商户")),
|
|
|
+ () -> controller.commit(commitBody(MCH_NO)));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 真服务 + mock mapper:要断言的是**写进去 / 查出去的东西**,不是「调了哪个方法」。 */
|
|
|
+ private LeshouyingOnboardingController controllerWithRealMerchantService(LsyMerchantMapper mapper) {
|
|
|
+ return new LeshouyingOnboardingController(new LsyMerchantService(mapper), onboardingService);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static AbstractWrapper<?, ?, ?> capturedSelectList(LsyMerchantMapper mapper) {
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
|
|
+ verify(mapper).selectList(captor.capture());
|
|
|
+ return (AbstractWrapper<?, ?, ?>) captor.getValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LeshouyingOnboardingController.CreateMerchantRequest createBody(
|
|
|
+ String enterpriseId, String merchantName) {
|
|
|
+ LeshouyingOnboardingController.CreateMerchantRequest b =
|
|
|
+ new LeshouyingOnboardingController.CreateMerchantRequest();
|
|
|
+ b.setEnterpriseId(enterpriseId);
|
|
|
+ b.setMerchantName(merchantName);
|
|
|
+ return b;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LsyCommitMerchantInfoDTO commitBody(String mchNo) {
|
|
|
+ LsyCommitMerchantInfoDTO d = new LsyCommitMerchantInfoDTO();
|
|
|
+ d.setMchNo(mchNo);
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LsyMerchantEntity row(String mchNo) {
|
|
|
+ LsyMerchantEntity r = new LsyMerchantEntity();
|
|
|
+ r.setId(1L);
|
|
|
+ r.setTenantId(TENANT);
|
|
|
+ r.setMchNo(mchNo);
|
|
|
+ r.setReviewStatus("PENDING");
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把当前主体放进 SecurityContext。{@code tenantId == null} 是 Step 0 表第 3 行的主体。 */
|
|
|
+ private static void login(Long tenantId, boolean isSuperuser) {
|
|
|
+ LoginUser u = new LoginUser();
|
|
|
+ u.setUserId(1L);
|
|
|
+ u.setUsername("tester");
|
|
|
+ u.setStatus("0");
|
|
|
+ u.setTenantId(tenantId);
|
|
|
+ u.setIsSuperuser(isSuperuser);
|
|
|
+ u.setAuthorities(List.of());
|
|
|
+ SecurityContextHolder.getContext().setAuthentication(
|
|
|
+ new UsernamePasswordAuthenticationToken(u, null, List.of()));
|
|
|
+ }
|
|
|
+}
|