|
@@ -0,0 +1,285 @@
|
|
|
|
|
+package com.payment.platform.module.payment.har.controller;
|
|
|
|
|
+
|
|
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
|
|
+import com.payment.platform.core.security.LoginUser;
|
|
|
|
|
+import com.payment.platform.module.payment.har.dto.HarCheckoutLinkVO;
|
|
|
|
|
+import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.har.service.HarCheckoutLinkService;
|
|
|
|
|
+import com.payment.platform.module.payment.har.service.HarMerchantService;
|
|
|
|
|
+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.Mock;
|
|
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
|
|
+import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
|
|
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
|
|
|
+import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.same;
|
|
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 管理端「取收款链接」(商户页操作列那个按钮背后的端点)。
|
|
|
|
|
+ * 逐条对标乐收赢的 {@code LeshouyingMerchantControllerTest}。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【本类只覆盖新增的这一个端点,且只覆盖它真正负责的事】其余方法级授权({@code save} /
|
|
|
|
|
+ * {@code delete} 的超管门禁、{@code list} 的权限串)与本功能的装配事实
|
|
|
|
|
+ * ({@code SecurityConfig.WHITE_LIST} / {@code TenantInnerInterceptor.CONDITIONAL_TABLES})
|
|
|
|
|
+ * 都不在本类 —— 后者由 {@code HarCheckoutWiringTest} 守。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【本类真正钉的是什么】这个端点的**归属判据**。它要同时服务两种调用方:
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>租户用户:只能给自己那一行取(拿到的链接决定钱进哪个汇安融商户编号);</li>
|
|
|
|
|
+ * <li>超管:可以给任意一行取(商户编号的录入恰恰只有超管能做 —— 见
|
|
|
|
|
+ * {@code HarMerchantController#save} 的门禁 —— 让超管录完号却取不到链接没道理)。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ * 判据必须与 {@code TenantInnerInterceptor} **用同一个开关**({@code LoginUser#getIsSuperuser})——
|
|
|
|
|
+ * 用别的开关(例如「tenantId 是不是 null」)会出现「一边以为放行、一边仍在过滤」
|
|
|
|
|
+ * 或更坏的「一边以为在过滤、一边已经放行」,两种组合界面上都看不出来。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【量具】本仓所有 {@code *ControllerTest} 都是**普通方法直调**控制器
|
|
|
|
|
+ * (不起 Spring 上下文、{@code @PreAuthorize} 一步都不参与)—— 所以「哪个服务方法被调用、
|
|
|
|
|
+ * 传了什么租户号」只能靠 {@code verify} 读,而「注解写的是什么」只能靠反射读(见最后一条用例)。
|
|
|
|
|
+ */
|
|
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
|
|
+class HarMerchantControllerTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Mock private HarMerchantService merchantService;
|
|
|
|
|
+ @Mock private HarCheckoutLinkService linkService;
|
|
|
|
|
+
|
|
|
|
|
+ private MockHttpServletRequest request;
|
|
|
|
|
+ private HarMerchantController controller;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ request = new MockHttpServletRequest("POST", "/api/v1/payment/har/merchant/1/checkout-link");
|
|
|
|
|
+ controller = new HarMerchantController(merchantService, linkService, request);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 必须清:{@code SecurityContextHolder} 是**线程本地且跨用例存活**的,
|
|
|
|
|
+ * 而 Surefire 默认在同一线程里跑完整个类。不清的话某条用例的 {@code login(null, true)}
|
|
|
|
|
+ * 会泄漏给后面每一条「不该放行」的用例,让它们集体变绿 ——
|
|
|
|
|
+ * 那种「全绿但什么也没验」的形态在本仓出现过。
|
|
|
|
|
+ */
|
|
|
|
|
+ @AfterEach
|
|
|
|
|
+ void clearSecurityContext() {
|
|
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /** 把当前主体放进 SecurityContext。 */
|
|
|
|
|
+ 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()));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static HarMerchantEntity merchant(long id, Long tenantId, String code, String name) {
|
|
|
|
|
+ HarMerchantEntity m = new HarMerchantEntity();
|
|
|
|
|
+ m.setId(id);
|
|
|
|
|
+ m.setTenantId(tenantId);
|
|
|
|
|
+ m.setMerchantCode(code);
|
|
|
|
|
+ m.setMerchantName(name);
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static HarCheckoutLinkEntity link(String code, String snapshotName, String token) {
|
|
|
|
|
+ HarCheckoutLinkEntity l = new HarCheckoutLinkEntity();
|
|
|
|
|
+ l.setId(9L);
|
|
|
|
|
+ l.setMerchantCode(code);
|
|
|
|
|
+ l.setMerchantName(snapshotName);
|
|
|
|
|
+ l.setToken(token);
|
|
|
|
|
+ l.setStatus("ENABLED");
|
|
|
|
|
+ return l;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────── 一、两种调用方,两条查法 ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 租户用户:谓词里**显式**写上自己的租户号。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 这与拦截器追加的那一条**重复**,是**故意**的(纵深防御):两道判据由两条完全
|
|
|
|
|
+ * 独立的代码路径写出,手写的那一条写错时拦截器那一条还在。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 快照那一格:应答里的 {@code merchantName} 必须取自**链接行**
|
|
|
|
|
+ * ({@code link.getMerchantName()}),不是商户行那一列 —— 这是本用例把两个名字
|
|
|
|
|
+ * 写成不同值的理由(真实流程里 {@code issue} 会把快照刷成商户行的名字,两者随即相同;
|
|
|
|
|
+ * 但「从哪个对象抄」是控制器自己的选择,抄错了没有任何一处会报错)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void checkoutLink_tenantUser_readsViaTenantScopedLeg_tenantIdFromPrincipal() {
|
|
|
|
|
+ login(3L, false);
|
|
|
|
|
+ HarMerchantEntity row = merchant(1L, 3L, "M003", "商户表里的名字");
|
|
|
|
|
+ when(merchantService.requireByIdForTenant(1L, 3L)).thenReturn(row);
|
|
|
|
|
+ when(linkService.issue(3L, "M003", "商户表里的名字"))
|
|
|
|
|
+ .thenReturn(link("M003", "链接行里的名字", "tok3"));
|
|
|
|
|
+ when(linkService.buildUrl(same("tok3"), same(request)))
|
|
|
|
|
+ .thenReturn("https://pay.example.com/#/checkout/har/tok3");
|
|
|
|
|
+
|
|
|
|
|
+ HarCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
|
|
|
|
|
+
|
|
|
|
|
+ verify(merchantService).requireByIdForTenant(1L, 3L);
|
|
|
|
|
+ verify(merchantService, never()).requireById(anyLong());
|
|
|
|
|
+ verify(linkService).issue(3L, "M003", "商户表里的名字");
|
|
|
|
|
+ assertEquals("M003", vo.getMerchantCode(), "商户编号要回给页面显示(操作员得知道自己在给谁取链接)");
|
|
|
|
|
+ assertEquals("链接行里的名字", vo.getMerchantName(),
|
|
|
|
|
+ "显示名取自**链接行**(顾客打开链接时看到的就是它),不是商户行那一列");
|
|
|
|
|
+ assertEquals("https://pay.example.com/#/checkout/har/tok3", vo.getUrl(),
|
|
|
|
|
+ "URL 必须来自服务层的 buildUrl(基址推导那套逻辑只有一份,"
|
|
|
|
|
+ + "控制器自己拼一个就是第二个副本)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 超管:不写租户谓词,**租户号取自那一行**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 这条是本端点存在两种调用方的**全部理由**:超管的 {@code LoginUser.tenantId}
|
|
|
|
|
+ * 可以是 null(它跨租户看,所以根本没有「自己的租户」可写),
|
|
|
|
|
+ * 于是若照 {@code HarCashierService#requireTenantId} 那样「取不到租户就 403」,
|
|
|
|
|
+ * 超管将**完全无法使用这个按钮**。
|
|
|
|
|
+ * ★ 这里显式断言 {@code requireByIdForTenant} **一次都没被调**:那是两条查法
|
|
|
|
|
+ * 不互相串味的正对照(少了它,一个「两条都调」的实现照样绿)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void checkoutLink_superuser_readsViaPkLeg_soItWorksWithoutOwnTenant() {
|
|
|
|
|
+ login(null, true);
|
|
|
|
|
+ HarMerchantEntity row = merchant(1L, 5L, "M005", "五号商户");
|
|
|
|
|
+ when(merchantService.requireById(1L)).thenReturn(row);
|
|
|
|
|
+ when(linkService.issue(5L, "M005", "五号商户")).thenReturn(link("M005", "五号商户", "tok5"));
|
|
|
|
|
+ when(linkService.buildUrl(same("tok5"), same(request)))
|
|
|
|
|
+ .thenReturn("https://pay.example.com/#/checkout/har/tok5");
|
|
|
|
|
+
|
|
|
|
|
+ HarCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
|
|
|
|
|
+
|
|
|
|
|
+ verify(merchantService).requireById(1L);
|
|
|
|
|
+ verify(merchantService, never()).requireByIdForTenant(anyLong(), any());
|
|
|
|
|
+ verify(linkService).issue(5L, "M005", "五号商户");
|
|
|
|
|
+ assertEquals("https://pay.example.com/#/checkout/har/tok5", vo.getUrl(),
|
|
|
|
|
+ "超管没有自己的租户号 —— 租户号必须取自那一行,两种调用方才都通");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────── 二、闸:取不到归属就拒绝 ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 非超管**且**没有归属租户 ⇒ 仍然走**带租户谓词**的那条查法,绝不落到超管那条。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 这不是假想的组合:写主体的那条路径把 {@code sys_user.tenant_id}(可空)
|
|
|
|
|
+ * 原样搬进 {@code LoginUser},而 {@code is_superuser} 是另一个开关。
|
|
|
|
|
+ * 这种主体在条件表上会被**跳过过滤**(没有租户可过滤)⇒ 若它落进超管那条腿,
|
|
|
|
|
+ * 就能按 id 读到**任意租户**的商户行、再拿那一行的商户编号发出一条收款链接。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么这条只断言「走了哪条腿」,不断言 403】那句 403 是**服务层的契约**
|
|
|
|
|
+ * ({@code HarMerchantService#requireByIdForTenant} 在 {@code tenantId == null} 时抛,
|
|
|
|
|
+ * 且发生在查库之前),证据在 {@code HarMerchantServiceTest}(那里跑的是真实现)。
|
|
|
|
|
+ * 在这里把 mock 设成「抛 403」再断言收到 403,验的是**测试自己设的桩**,不是被测代码
|
|
|
|
|
+ * —— 所以本类只测控制器真正负责的那一件事:**分岔**。
|
|
|
|
|
+ * 两条腿都用 {@code never()} 互为正对照。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void checkoutLink_nonSuperuserWithoutTenant_stillTakesTheTenantScopedLeg() {
|
|
|
|
|
+ login(null, false);
|
|
|
|
|
+ when(merchantService.requireByIdForTenant(1L, null))
|
|
|
|
|
+ .thenReturn(merchant(1L, null, "M001", null));
|
|
|
|
|
+ when(linkService.issue(null, "M001", null)).thenReturn(link("M001", null, "tok1"));
|
|
|
|
|
+ when(linkService.buildUrl(same("tok1"), same(request)))
|
|
|
|
|
+ .thenReturn("https://pay.example.com/#/checkout/har/tok1");
|
|
|
|
|
+
|
|
|
|
|
+ controller.checkoutLink(1L);
|
|
|
|
|
+
|
|
|
|
|
+ verify(merchantService).requireByIdForTenant(1L, null);
|
|
|
|
|
+ verify(merchantService, never()).requireById(anyLong());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 取不到登录主体 ⇒ 403(**默认拒绝**,不是默认放行),且一条库都不查、一个字都不写。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void checkoutLink_noPrincipal_403_andTouchesNothing() {
|
|
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> controller.checkoutLink(1L));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(403, e.getCode(), "取不到主体时必须拒绝,不能默认放行");
|
|
|
|
|
+ assertEquals("取不到当前登录身份,无法生成收款链接", e.getMessage());
|
|
|
|
|
+ verifyNoInteractions(merchantService, linkService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────── 三、查不到就不发链接 ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 别租户 / 不存在的商户行,**同一个 404**(不区分这两种 —— 区分开等于给出一个
|
|
|
|
|
+ * 越权探测面),且**不生成链接**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 断言 {@code verifyNoInteractions(linkService)} 而不是只看异常类型:
|
|
|
|
|
+ * 若实现在闸之前先 {@code issue(...)},那么「拒绝」只是**发晚了** ——
|
|
|
|
|
+ * 链接行已经落库、token 已经发出去。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void checkoutLink_missingOrForeignMerchant_404_andNeverIssuesALink() {
|
|
|
|
|
+ login(3L, false);
|
|
|
|
|
+ when(merchantService.requireByIdForTenant(1L, 3L))
|
|
|
|
|
+ .thenThrow(new BusinessException(404, "商户配置不存在"));
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> controller.checkoutLink(1L));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(404, e.getCode(), "不区分「不存在」与「不属于你」—— 区分开等于给出一个越权探测面");
|
|
|
|
|
+ verifyNoInteractions(linkService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────── 四、接口形状(契约) ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 前端要调的就是这个形状,所以它得有个钉住的地方:**路径 + 方法 + 权限串**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 权限串**复用** {@code merchant:list}(本控制器 {@code list} 用的那一条),
|
|
|
|
|
+ * 不新增权限点 —— 因此**不需要新的菜单 SQL**:能看见商户页的人本来就读得到这一行的
|
|
|
|
|
+ * 每一列({@code list} 返回整个 {@code HarMerchantVO},含 {@code merchantCode});
|
|
|
|
|
+ * 本端点做的是拿那一行的编号换一条链接,没有多给出任何一项**读**的能力。
|
|
|
|
|
+ * 「给哪个租户」由上面那两道闸决定,不由权限串决定。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void checkoutLink_contract_permissionStringIsTheSameOneAsList_andPathIsTheFrontendContract() throws Exception {
|
|
|
|
|
+ var method = HarMerchantController.class.getMethod("checkoutLink", Long.class);
|
|
|
|
|
+
|
|
|
|
|
+ PreAuthorize pa = method.getAnnotation(PreAuthorize.class);
|
|
|
|
|
+ assertNotNull(pa, "端点必须有 @PreAuthorize(它会按 id 读任意商户行并写一行链接)");
|
|
|
|
|
+ assertEquals("@perm.hasAny('module_payment:har:merchant:list')", pa.value(),
|
|
|
|
|
+ "权限串必须与 list 逐字相同(复用同一个权限点,不新增菜单 SQL)");
|
|
|
|
|
+
|
|
|
|
|
+ PreAuthorize listPa = HarMerchantController.class
|
|
|
|
|
+ .getMethod("list", int.class, int.class, Long.class, String.class, String.class, String.class)
|
|
|
|
|
+ .getAnnotation(PreAuthorize.class);
|
|
|
|
|
+ assertNotNull(listPa);
|
|
|
|
|
+ assertEquals(listPa.value(), pa.value(),
|
|
|
|
|
+ "两处必须真的相等 —— 只写死同一个字面量的话,有人把两边一起改掉时照样绿");
|
|
|
|
|
+
|
|
|
|
|
+ PostMapping pm = method.getAnnotation(PostMapping.class);
|
|
|
|
|
+ assertNotNull(pm, "取链接会**写**一行(首次调用要建链接行)⇒ 必须是 POST,不是 GET");
|
|
|
|
|
+ RequestMapping rm = HarMerchantController.class.getAnnotation(RequestMapping.class);
|
|
|
|
|
+ assertNotNull(rm, "类级 @RequestMapping 不得为空 —— 真实路径拼不出来");
|
|
|
|
|
+
|
|
|
|
|
+ // ★ 两段各自与**同一个字面量**比:分开断言会在「有人把两段一起改掉」时照样绿
|
|
|
|
|
+ // —— 而那个改动正是前端 404 的成因。
|
|
|
|
|
+ assertEquals(List.of("/{id}/checkout-link"), List.of(pm.value()));
|
|
|
|
|
+ assertEquals("/payment/har/merchant/{id}/checkout-link",
|
|
|
|
|
+ rm.value()[0] + pm.value()[0],
|
|
|
|
|
+ "前后端契约路径。前端商户页操作列按这一条调;改这段而不同步前端 ⇒ 按钮点了 404");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|