|
@@ -0,0 +1,130 @@
|
|
|
|
|
+package com.payment.platform.core.security;
|
|
|
|
|
+
|
|
|
|
|
+import com.payment.platform.core.tenant.TenantInnerInterceptor;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
|
|
+import org.springframework.util.AntPathMatcher;
|
|
|
|
|
+
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 汇安融收款链接(顾客页)那条腿的**装配**守卫 —— 与 {@code LeshouyingWiringTest} 同形。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>这里钉的每一条都是「模块内的用例覆盖不到、漏改后症状又都长得不像自己」的东西:
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li><b>白名单</b>:{@code SecurityConfig.WHITE_LIST} 少了 {@code /payment/har/checkout/**}
|
|
|
|
|
+ * ⇒ 顾客打开链接被 Spring Security 挡成 401/403,而这条路径**本来就不该有登录态**。
|
|
|
|
|
+ * ★ 别指望乐收赢那条 {@code /payment/checkout/**} 兜住 —— 两者前缀不同,
|
|
|
|
|
+ * Ant 模式匹配不到(本类第一条用例里有这条前提复核,写死)。</li>
|
|
|
|
|
+ * <li><b>条件表</b>:{@code TenantInnerInterceptor.CONDITIONAL_TABLES} 少了
|
|
|
|
|
+ * {@code pay_har_checkout_link} ⇒ 未认证查询被追加 {@code AND tenant_id = 0}
|
|
|
|
|
+ * ⇒ **每条链接都查不到**,表现是顾客页一律「链接无效」,而日志里一个字都没有。</li>
|
|
|
|
|
+ * <li><b>认证面</b>:它是顾客页,不是下游系统 ⇒ {@code TenantApiKeyAuthFilter.shouldNotFilter}
|
|
|
|
|
+ * 必须为 true(跳过)。反向守卫:哪天有人把顾客端点挪进 {@code /payment/openapi/**}
|
|
|
|
|
+ * 或往 {@code OPENAPI_PREFIXES} 里加前缀,它立刻红 —— 那种挪动会把一条**免登录**
|
|
|
|
|
+ * 路径变成「必须持有 ApiKey」。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ */
|
|
|
|
|
+class HarCheckoutWiringTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** 顾客页的两个真实形状路径(开屏 / 下单)。 */
|
|
|
|
|
+ private static final String[] CHECKOUT_PATHS = {
|
|
|
|
|
+ "/payment/har/checkout/tok",
|
|
|
|
|
+ "/payment/har/checkout/tok/orders",
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** 被测 filter;依赖字段在 shouldNotFilter 路径上都不被触碰,故可传 null(同 LeshouyingWiringTest)。 */
|
|
|
|
|
+ private final TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null);
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 顾客页两条路径必须被白名单放行。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 用真 Ant 匹配器撞**模式**,不是 {@code Arrays.asList(...).contains(字面量)}
|
|
|
|
|
+ * —— 白名单条目存的是 {@code /**} 形态,contains 对它恒为 false(同 LeshouyingWiringTest
|
|
|
|
|
+ * 里那段「近似代理」说明)。顺带一条前提复核:乐收赢那条 {@code /payment/checkout/**}
|
|
|
|
|
+ * **匹配不到**本前缀,所以这一条登记是**独立的、不可省的**(删掉它本用例立刻红,
|
|
|
|
|
+ * 而不是靠邻居兜住)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void checkoutPaths_areWhitelisted_matchingAntPattern() throws Exception {
|
|
|
|
|
+ AntPathMatcher matcher = new AntPathMatcher();
|
|
|
|
|
+ // 前提复核:乐收赢那条白名单条目覆盖不到 /payment/har/checkout/**,本登记不可省
|
|
|
|
|
+ assertFalse(matcher.match("/payment/checkout/**", "/payment/har/checkout/tok"),
|
|
|
|
|
+ "前提复核:若这条成立于将来某次改写,说明前缀形态变了,本用例的独立断言要重审");
|
|
|
|
|
+
|
|
|
|
|
+ for (String path : CHECKOUT_PATHS) {
|
|
|
|
|
+ assertTrue(whitelisted(path),
|
|
|
|
|
+ "顾客页路径未加入 SecurityConfig.WHITE_LIST(会被挡成 403): " + path);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 反面对照:管理端(要登录)的汇安融路径**不得**被白名单覆盖。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 否定式读数配正对照:先用一条**确实被放行**的同前缀路径证明匹配器与读取姿势是好的
|
|
|
|
|
+ * ({@code /payment/openapi/har/**} 命中),再断言管理端两条不被命中 ——
|
|
|
|
|
+ * 否则「没命中」可能只是量具坏了(本仓吃过这个亏)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void adminHarEndpoints_areNotWhitelisted() throws Exception {
|
|
|
|
|
+ assertTrue(whitelisted("/payment/openapi/har/barcode/pay"),
|
|
|
|
|
+ "正对照:同前缀的开放接口确实被放行 —— 证明匹配器读取姿势是好的");
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(whitelisted("/payment/har/merchant"),
|
|
|
|
|
+ "管理端商户页必须登录:若有人把白名单写宽成 /payment/har/**,这条变红");
|
|
|
|
|
+ assertFalse(whitelisted("/payment/har/merchant/1/checkout-link"),
|
|
|
|
|
+ "管理端「取收款链接」必须登录(它是发放腿,租户号从登录主体推)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void conditionalTables_containHarCheckoutLink() throws Exception {
|
|
|
|
|
+ Field f = TenantInnerInterceptor.class.getDeclaredField("CONDITIONAL_TABLES");
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ Set<String> tables = (Set<String>) f.get(null);
|
|
|
|
|
+
|
|
|
|
|
+ for (String t : new String[]{"pay_har_merchant", "pay_har_shop", "pay_har_order",
|
|
|
|
|
+ "pay_har_checkout_link"}) {
|
|
|
|
|
+ assertTrue(tables.contains(t),
|
|
|
|
|
+ "未加入 CONDITIONAL_TABLES → 无认证场景查询被追加 tenant_id=0 → 顾客页一律「链接无效」且日志无痕: " + t);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void checkoutPaths_doNotGoThroughApiKeyAuth() {
|
|
|
|
|
+ for (String path : CHECKOUT_PATHS) {
|
|
|
|
|
+ assertTrue(filter.shouldNotFilter(realRequest(path)),
|
|
|
|
|
+ "顾客页不是下游系统,不该经 ApiKey 认证面(应当跳过该 filter): " + path);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /** 白名单是否覆盖该路径(读生产常量 + 真 Ant 匹配器,同 LeshouyingWiringTest 的写法)。 */
|
|
|
|
|
+ private static boolean whitelisted(String path) throws Exception {
|
|
|
|
|
+ Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
|
|
|
|
|
+ f.setAccessible(true);
|
|
|
|
|
+ String[] whiteList = (String[]) f.get(null);
|
|
|
|
|
+ AntPathMatcher matcher = new AntPathMatcher();
|
|
|
|
|
+ return Arrays.stream(whiteList).anyMatch(w -> matcher.match(w, path));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 构造**真实容器形状**的请求:servletPath 不含 context-path,requestURI 含。
|
|
|
|
|
+ * ★ 若两者相等(旧写法),测试就分辨不出被测代码用的是哪个访问器 ——
|
|
|
|
|
+ * 同 LeshouyingWiringTest#realRequest。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static MockHttpServletRequest realRequest(String path) {
|
|
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest("POST", path);
|
|
|
|
|
+ req.setContextPath("/api/v1");
|
|
|
|
|
+ req.setServletPath(path);
|
|
|
|
|
+ req.setRequestURI("/api/v1" + path);
|
|
|
|
|
+ assertNotEquals(req.getServletPath(), req.getRequestURI(),
|
|
|
|
|
+ "mock 必须是真实容器形状:servletPath ≠ requestURI,否则测不出访问器用错");
|
|
|
|
|
+ return req;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|