|
|
@@ -0,0 +1,128 @@
|
|
|
+package com.payment.platform.module.payment.unified.controller;
|
|
|
+
|
|
|
+import com.payment.platform.core.security.SecurityConfig;
|
|
|
+import com.payment.platform.core.security.TenantApiKeyAuthFilter;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 统一支付通道的**开放 API 认证面**接线守卫。
|
|
|
+ *
|
|
|
+ * <p>这条通道要接入开放 API,必须同时动两处:
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code SecurityConfig.WHITE_LIST} 放行 {@code /payment/openapi/unified/**}
|
|
|
+ * 与回调 {@code /payment/notify/unified/**};</li>
|
|
|
+ * <li>{@code TenantApiKeyAuthFilter.OPENAPI_PREFIXES} 认领 {@code /payment/openapi/unified}
|
|
|
+ * —— 白名单放行而 filter 不认,就是「被 permitAll + 无人认证」的**零鉴权**。</li>
|
|
|
+ * </ul>
|
|
|
+ * 2026-09-24 的零鉴权事故正是这两份清单只改了一边;{@code OpenapiAuthWiringTest}
|
|
|
+ * 负责通用一致性,本类负责把统一支付这 5 条端点**逐条**钉死。
|
|
|
+ *
|
|
|
+ * <p>全部读数走反射读**真实数组**,不在测试里复制实现常量 —— 复制一份等于测自己。
|
|
|
+ */
|
|
|
+class UnifiedAuthWiringTest {
|
|
|
+
|
|
|
+ private static final String PREFIX = "/payment/openapi/unified";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 被测 filter。{@code shouldNotFilter} 是 protected,本测试不在 core.security 包内,
|
|
|
+ * 故用同形子类把判定暴露成 public 方法(不改生产可见性);三个依赖字段在该路径上不被触碰,可传 null。
|
|
|
+ */
|
|
|
+ private final AuthProbe filter = new AuthProbe();
|
|
|
+
|
|
|
+ private static final class AuthProbe extends TenantApiKeyAuthFilter {
|
|
|
+ AuthProbe() {
|
|
|
+ super(null, null, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ boolean requiresAuth(MockHttpServletRequest request) {
|
|
|
+ return !shouldNotFilter(request);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void openapiPrefixes_containsTheUnifiedPrefix() throws Exception {
|
|
|
+ List<String> prefixes = Arrays.asList(readStringArray(TenantApiKeyAuthFilter.class, "OPENAPI_PREFIXES"));
|
|
|
+
|
|
|
+ assertTrue(prefixes.contains(PREFIX),
|
|
|
+ "OPENAPI_PREFIXES 未认领 " + PREFIX + " ⇒ 该前缀被白名单放行却无人认证 = 零鉴权。读数: " + prefixes);
|
|
|
+ // 正对照:既有通道必须仍在,证明读到的是真数组而不是空壳。
|
|
|
+ assertTrue(prefixes.contains("/payment/openapi/alipay"),
|
|
|
+ "既有 alipay 前缀缺失,说明本用例的量具读错了数组。读数: " + prefixes);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void openapiAccountPaths_unifiedSubsetIsExactlyTheFiveEndpoints() throws Exception {
|
|
|
+ List<String> allPaths = Arrays.asList(
|
|
|
+ readStringArray(TenantApiKeyAuthFilter.class, "OPENAPI_ACCOUNT_PATHS"));
|
|
|
+
|
|
|
+ // 该数组是跨通道全局清单,这里只按 unified 前缀取子集核对(见任务简报 R15)。
|
|
|
+ Set<String> unified = allPaths.stream()
|
|
|
+ .filter(p -> p.startsWith(PREFIX + "/"))
|
|
|
+ .collect(Collectors.toSet());
|
|
|
+
|
|
|
+ assertEquals(Set.of(
|
|
|
+ PREFIX + "/order/create",
|
|
|
+ PREFIX + "/order/query",
|
|
|
+ PREFIX + "/refund",
|
|
|
+ PREFIX + "/refund/query",
|
|
|
+ PREFIX + "/channel-user/jump-url"),
|
|
|
+ unified,
|
|
|
+ "OPENAPI_ACCOUNT_PATHS 中 unified 端点清单不符。读数: " + unified);
|
|
|
+
|
|
|
+ // 正对照:既有 alipay 端点必须仍在数组中,证明读到的是真数组。
|
|
|
+ assertTrue(allPaths.contains("/payment/openapi/alipay/order/create"),
|
|
|
+ "既有 alipay 端点缺失,说明本用例的量具读错了数组。读数: " + allPaths);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void whiteList_permitsUnifiedOpenapiAndNotify() throws Exception {
|
|
|
+ List<String> whiteList = Arrays.asList(readStringArray(SecurityConfig.class, "WHITE_LIST"));
|
|
|
+
|
|
|
+ assertTrue(whiteList.contains(PREFIX + "/**"),
|
|
|
+ "WHITE_LIST 未放行 " + PREFIX + "/**,开放 API 会在授权层被拦成空 403。读数: " + whiteList);
|
|
|
+ assertTrue(whiteList.contains("/payment/notify/unified/**"),
|
|
|
+ "WHITE_LIST 未放行 /payment/notify/unified/**,上游回调进不来。读数: " + whiteList);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void shouldNotFilter_unifiedOpenapiRequiresAuth_butUnifiedNotifyDoesNot() {
|
|
|
+ assertTrue(filter.requiresAuth(realRequest(PREFIX + "/order/create")),
|
|
|
+ "开放端点必须经过 ApiKey 认证(shouldNotFilter=false)");
|
|
|
+ assertTrue(filter.requiresAuth(realRequest(PREFIX)),
|
|
|
+ "bare 前缀经 AntPathMatcher 也匹配 " + PREFIX + "/**,必须同样认证");
|
|
|
+ // 正对照:回调入口不走 ApiKey 认证(真实性由通道验签保证)。
|
|
|
+ assertFalse(filter.requiresAuth(realRequest("/payment/notify/unified/order")),
|
|
|
+ "回调路径不应被 ApiKey filter 拦截(shouldNotFilter=true)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
+
|
|
|
+ private static String[] readStringArray(Class<?> owner, String fieldName) throws Exception {
|
|
|
+ Field f = owner.getDeclaredField(fieldName);
|
|
|
+ f.setAccessible(true);
|
|
|
+ return (String[]) f.get(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构造**真实容器形状**的请求:servletPath 不含 context-path,requestURI 含。
|
|
|
+ * 同 {@code OpenapiAuthWiringTest#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;
|
|
|
+ }
|
|
|
+}
|