feat(gateway): 应用级免签(ANON) + 签名限流/per-route 超时 + 代理测试页
build / build-push (push) Successful in 1m47s
build / build-push (push) Successful in 1m47s
- open_api_app 表(docs/sql/04_app.sql) + OpenApiApp/OpenApiAppRepository/AppAuthRegistry: authMode=ANON 应用级免签,支持 '*' 路径通配窗口,有效期 ≤31 天惰性到期; PUT 带 authMode=ANON 时 expireAt 必传 - SignedRateLimiter + RateLimitFilter:按签名键限流(fail-closed); RouteSpec 增加 per-route 超时与请求体积上限,StreamingForwarder 执行超时 - ProxyTestController:网关代理测试页后端接口 - SignVerifyFilter/DispatchFilter/InternalAdminController 及对应测试接线 - .gitignore 补 gateway-run.log / tmp_*.sql(本地运行产物不入库) 验证:全量 171 用例绿 + 双轮冒烟(2026-10-03)
This commit is contained in:
@@ -1,28 +1,35 @@
|
||||
package com.yunhai.platform.gateway.admin;
|
||||
|
||||
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
|
||||
import com.yunhai.platform.gateway.entity.OpenApiApp;
|
||||
import com.yunhai.platform.gateway.entity.OpenApiLog;
|
||||
import com.yunhai.platform.gateway.entity.OpenApiRoute;
|
||||
import com.yunhai.platform.gateway.entity.OpenApiTarget;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiTargetRepository;
|
||||
import com.yunhai.platform.gateway.route.RouteRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
@@ -44,7 +51,9 @@ class InternalAdminControllerTest {
|
||||
private OpenApiRouteRepository routeRepo;
|
||||
private OpenApiTargetRepository targetRepo;
|
||||
private OpenApiLogRepository logRepo;
|
||||
private OpenApiAppRepository appRepo;
|
||||
private RouteRegistry registry;
|
||||
private AppAuthRegistry appRegistry;
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
@@ -52,18 +61,23 @@ class InternalAdminControllerTest {
|
||||
routeRepo = Mockito.mock(OpenApiRouteRepository.class);
|
||||
targetRepo = Mockito.mock(OpenApiTargetRepository.class);
|
||||
logRepo = Mockito.mock(OpenApiLogRepository.class);
|
||||
appRepo = Mockito.mock(OpenApiAppRepository.class);
|
||||
registry = Mockito.mock(RouteRegistry.class);
|
||||
appRegistry = Mockito.mock(AppAuthRegistry.class);
|
||||
|
||||
InternalAdminController controller = new InternalAdminController();
|
||||
controller.setRepos(routeRepo, targetRepo, logRepo, registry);
|
||||
controller.setRepos(routeRepo, targetRepo, logRepo, registry, appRepo, appRegistry);
|
||||
mvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
|
||||
when(routeRepo.findAll(any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(Arrays.asList(route(1L))));
|
||||
when(targetRepo.findAll(any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(Arrays.asList(target(5L))));
|
||||
when(appRepo.findAll(any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(Arrays.asList(appRow(7L))));
|
||||
when(registry.reload()).thenReturn(22);
|
||||
when(registry.reloadTargets()).thenReturn(13);
|
||||
when(appRegistry.reload()).thenReturn(2);
|
||||
when(logRepo.pageLatest(any(), any(), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(Arrays.asList(logRow())));
|
||||
}
|
||||
@@ -123,6 +137,39 @@ class InternalAdminControllerTest {
|
||||
verify(routeRepo, never()).save(any());
|
||||
}
|
||||
|
||||
/** 能力补全 3:timeout/bodyLimit 越界不落库(D6:timeout ∈ [1000,120000],bodyLimit ∈ [1,100]) */
|
||||
@Test
|
||||
void routeUpdate_timeoutOrBodyLimitOutOfRange_bizError() throws Exception {
|
||||
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
|
||||
String[] illegal = new String[]{
|
||||
"{\"timeoutMs\":999}", "{\"timeoutMs\":120001}",
|
||||
"{\"requestBodyLimitMb\":0}", "{\"requestBodyLimitMb\":101}"};
|
||||
for (String body : illegal) {
|
||||
mvc.perform(put("/api/internal/admin/routes/1")
|
||||
.contentType("application/json")
|
||||
.content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
}
|
||||
verify(routeRepo, never()).save(any());
|
||||
verify(registry, never()).reload();
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeUpdate_legalTimeoutAndBodyLimit_savesAndReloads() throws Exception {
|
||||
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
|
||||
mvc.perform(put("/api/internal/admin/routes/1")
|
||||
.contentType("application/json")
|
||||
.content("{\"timeoutMs\":1000,\"requestBodyLimitMb\":100}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.loaded").value(22));
|
||||
ArgumentCaptor<OpenApiRoute> saved = ArgumentCaptor.forClass(OpenApiRoute.class);
|
||||
verify(routeRepo).save(saved.capture());
|
||||
assertThat(saved.getValue().getTimeoutMs()).isEqualTo(1000);
|
||||
assertThat(saved.getValue().getRequestBodyLimitMb()).isEqualTo(100);
|
||||
verify(registry).reload();
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeUpdate_missingRow_bizError() throws Exception {
|
||||
when(routeRepo.findById(99L)).thenReturn(java.util.Optional.empty());
|
||||
@@ -215,4 +262,218 @@ class InternalAdminControllerTest {
|
||||
l.setCostMs(5);
|
||||
return l;
|
||||
}
|
||||
|
||||
private static OpenApiApp appRow(Long id) {
|
||||
OpenApiApp a = new OpenApiApp();
|
||||
a.setId(id);
|
||||
a.setAppKey("100009");
|
||||
a.setAppName("冒烟-免签");
|
||||
a.setAuthMode("ANON");
|
||||
a.setExpireAt(LocalDateTime.now().plusDays(7));
|
||||
a.setEnabled(true);
|
||||
return a;
|
||||
}
|
||||
|
||||
/* ============ 应用治理接口(C 方向 G12-9)============ */
|
||||
|
||||
@Test
|
||||
void apps_listSorted() throws Exception {
|
||||
mvc.perform(get("/api/internal/admin/apps"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(1))
|
||||
.andExpect(jsonPath("$.content[0].appKey").value("100009"))
|
||||
.andExpect(jsonPath("$.content[0].authMode").value("ANON"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_anonWithLegalExpire_savesAndReloads() throws Exception {
|
||||
when(appRepo.existsByAppKey("100018")).thenReturn(false);
|
||||
String expire = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.format(LocalDateTime.now().plusDays(7));
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"100018\",\"appName\":\"新应用\",\"authMode\":\"ANON\",\"expireAt\":\"" + expire + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.apps").value(2));
|
||||
ArgumentCaptor<OpenApiApp> cap = ArgumentCaptor.forClass(OpenApiApp.class);
|
||||
verify(appRepo).save(cap.capture());
|
||||
assertThat(cap.getValue().getExpireAt()).isNotNull();
|
||||
assertThat(cap.getValue().getGmtCreate()).as("gmt 手填").isNotNull();
|
||||
verify(appRegistry).reload();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_signedMode_expireCleared() throws Exception {
|
||||
when(appRepo.existsByAppKey("100018")).thenReturn(false);
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"100018\",\"authMode\":\"SIGNED\"}"))
|
||||
.andExpect(status().isOk());
|
||||
ArgumentCaptor<OpenApiApp> cap = ArgumentCaptor.forClass(OpenApiApp.class);
|
||||
verify(appRepo).save(cap.capture());
|
||||
assertThat(cap.getValue().getExpireAt()).as("SIGNED 行不设到期").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_duplicateAppKey_bizErrorNoSave() throws Exception {
|
||||
when(appRepo.existsByAppKey("100009")).thenReturn(true);
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"100009\",\"authMode\":\"SIGNED\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("已存在")));
|
||||
verify(appRepo, never()).save(any());
|
||||
verify(appRegistry, never()).reload();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_duplicateWindowRow_bizError() throws Exception {
|
||||
// '*' 匿名窗口行全局至多一行
|
||||
when(appRepo.existsByAppKey("*")).thenReturn(true);
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"*\",\"authMode\":\"ANON\",\"expireAt\":\"2099-01-01 00:00:00\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("匿名窗口")));
|
||||
verify(appRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_blankOrOverlongAppKey_bizError() throws Exception {
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\" \",\"authMode\":\"SIGNED\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"" + new String(new char[65]).replace('\0', 'k') + "\",\"authMode\":\"SIGNED\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
verify(appRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_illegalAuthMode_bizError() throws Exception {
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"100018\",\"authMode\":\"OPEN\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("authMode")));
|
||||
verify(appRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_anonMissingExpire_bizError() throws Exception {
|
||||
when(appRepo.existsByAppKey("100018")).thenReturn(false);
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"100018\",\"authMode\":\"ANON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("expireAt")));
|
||||
verify(appRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_expireOver31Days_bizError() throws Exception {
|
||||
when(appRepo.existsByAppKey("100018")).thenReturn(false);
|
||||
String expire = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.format(LocalDateTime.now().plusDays(32));
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"100018\",\"authMode\":\"ANON\",\"expireAt\":\"" + expire + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("31")));
|
||||
verify(appRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appCreate_expireInPast_bizError() throws Exception {
|
||||
when(appRepo.existsByAppKey("100018")).thenReturn(false);
|
||||
String expire = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.format(LocalDateTime.now().minusMinutes(1));
|
||||
mvc.perform(post("/api/internal/admin/apps")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"100018\",\"authMode\":\"ANON\",\"expireAt\":\"" + expire + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("晚于")));
|
||||
verify(appRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appUpdate_appKeyInBody_rejected() throws Exception {
|
||||
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(appRow(7L)));
|
||||
mvc.perform(put("/api/internal/admin/apps/7")
|
||||
.contentType("application/json")
|
||||
.content("{\"appKey\":\"100999\",\"remark\":\"x\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("appKey 不可改")));
|
||||
verify(appRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appUpdate_switchToAnonWithoutExpire_bizError() throws Exception {
|
||||
OpenApiApp signed = appRow(7L);
|
||||
signed.setAuthMode("SIGNED");
|
||||
signed.setExpireAt(null);
|
||||
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(signed));
|
||||
mvc.perform(put("/api/internal/admin/apps/7")
|
||||
.contentType("application/json")
|
||||
.content("{\"authMode\":\"ANON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2))
|
||||
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("expireAt")));
|
||||
verify(appRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appUpdate_switchBackToSigned_expireCleared() throws Exception {
|
||||
OpenApiApp anon = appRow(7L);
|
||||
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(anon));
|
||||
mvc.perform(put("/api/internal/admin/apps/7")
|
||||
.contentType("application/json")
|
||||
.content("{\"authMode\":\"SIGNED\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.apps").value(2));
|
||||
verify(appRepo).save(anon);
|
||||
assertThat(anon.getExpireAt()).as("切回 SIGNED 清 expire_at").isNull();
|
||||
verify(appRegistry).reload();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appUpdate_anonRowRenewalWithExpire_restartsWindow() throws Exception {
|
||||
OpenApiApp anon = appRow(7L);
|
||||
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(anon));
|
||||
String expire = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.format(LocalDateTime.now().plusDays(30));
|
||||
mvc.perform(put("/api/internal/admin/apps/7")
|
||||
.contentType("application/json")
|
||||
.content("{\"expireAt\":\"" + expire + "\"}"))
|
||||
.andExpect(status().isOk());
|
||||
ArgumentCaptor<OpenApiApp> cap = ArgumentCaptor.forClass(OpenApiApp.class);
|
||||
verify(appRepo).save(cap.capture());
|
||||
assertThat(cap.getValue().getExpireAt()).as("续期=重新起算").isAfter(LocalDateTime.now().plusDays(29));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appUpdate_anonRowPlainEdit_keepsExpire() throws Exception {
|
||||
// 既有 ANON 行只改备注/启停:不强制重传 expireAt,保留原到期
|
||||
OpenApiApp anon = appRow(7L);
|
||||
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(anon));
|
||||
mvc.perform(put("/api/internal/admin/apps/7")
|
||||
.contentType("application/json")
|
||||
.content("{\"remark\":\"改备注\",\"enabled\":false}"))
|
||||
.andExpect(status().isOk());
|
||||
verify(appRepo).save(anon);
|
||||
assertThat(anon.getRemark()).isEqualTo("改备注");
|
||||
assertThat(anon.isEnabled()).isFalse();
|
||||
assertThat(anon.getExpireAt()).as("原到期保留").isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.yunhai.platform.gateway.admin;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 「代理测试」端点(N 系治理小页):回环打真实链的核心契约——
|
||||
* <ul>
|
||||
* <li>URL 容忍式解析与形状闸(/{target}/oapi/**,第二段必须恰为 oapi);</li>
|
||||
* <li>容错编码:空格/中文百分号编码、既有 %xx 原样保留;</li>
|
||||
* <li>签名可被平台 {@link SignUtil#verifyRequestWithExpire} 验回(探针与验签同一 canonical,
|
||||
* query 排序口径共享);</li>
|
||||
* <li>密钥两来源(INPUT 页面明传 / REDIS 密钥链解析 fail-closed);</li>
|
||||
* <li>POST body 透传与非法入参 HTTP 200 + {code:2}。</li>
|
||||
* </ul>
|
||||
* 回环目标用 JDK 内置 HttpServer 冒充(单测无真实网关端口);standalone MockMvc 不装 advice,
|
||||
* 成功响应为裸 Map(线上由 ApiResponseAdvice 包 {code,msg,data})。
|
||||
*/
|
||||
class ProxyTestControllerTest {
|
||||
|
||||
/** 与 SignUtil.checkSecret 对齐:≥16 位 */
|
||||
private static final String SECRET = "probe-secret-0123456789";
|
||||
|
||||
private HttpServer server;
|
||||
private String loopbackBase;
|
||||
private final AtomicReference<String> capturedMethod = new AtomicReference<>();
|
||||
private final AtomicReference<String> capturedUri = new AtomicReference<>();
|
||||
private final AtomicReference<byte[]> capturedBody = new AtomicReference<>();
|
||||
private final Map<String, String> capturedHeaders = new HashMap<>();
|
||||
|
||||
private GatewayKeyService keyService;
|
||||
private ProxyTestController controller;
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", new CapturingHandler());
|
||||
server.start();
|
||||
loopbackBase = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||
|
||||
keyService = Mockito.mock(GatewayKeyService.class);
|
||||
controller = new ProxyTestController();
|
||||
controller.setKeyService(keyService);
|
||||
controller.setLoopbackBase(loopbackBase);
|
||||
mvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private class CapturingHandler implements HttpHandler {
|
||||
@Override
|
||||
public void handle(HttpExchange ex) throws IOException {
|
||||
capturedMethod.set(ex.getRequestMethod());
|
||||
capturedUri.set(ex.getRequestURI().toString());
|
||||
// JDK Headers 会按自身规则归一化键大小写,这里统一小写存取避免猜测归一形
|
||||
ex.getRequestHeaders().forEach((k, vs) -> capturedHeaders.put(k.toLowerCase(), String.join(",", vs)));
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try (InputStream in = ex.getRequestBody()) {
|
||||
byte[] buf = new byte[1024];
|
||||
int n;
|
||||
while ((n = in.read(buf)) >= 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
capturedBody.set(out.toByteArray());
|
||||
byte[] resp = "{\"code\":0,\"msg\":\"\",\"data\":{\"ok\":true}}".getBytes(StandardCharsets.UTF_8);
|
||||
ex.getResponseHeaders().set("Content-Type", "application/json");
|
||||
ex.sendResponseHeaders(200, resp.length);
|
||||
try (OutputStream os = ex.getResponseBody()) {
|
||||
os.write(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_shapeGate() {
|
||||
// 合法:带 scheme + 带无 scheme 两种贴法
|
||||
ProxyTestController.Parsed p = ProxyTestController.parse("http://gw.example.com/userbase/oapi/user/byId?id=1");
|
||||
assertEquals("userbase", p.target);
|
||||
assertEquals("/userbase/oapi/user/byId", p.rawPath);
|
||||
assertEquals("id=1", p.rawQuery);
|
||||
p = ProxyTestController.parse("notice/oapi/notice/getDict");
|
||||
assertEquals("notice", p.target);
|
||||
|
||||
// 非法:无路径 / 单段 / 第二段非 oapi
|
||||
assertThrows(IllegalArgumentException.class, () -> ProxyTestController.parse("http://gw.example.com"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ProxyTestController.parse("/userbase"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ProxyTestController.parse("/userbase/api/user/list"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lenientEncode_keepsLegalAndEncodesRaw() {
|
||||
String enc = ProxyTestController.lenientUriEncode("/userbase/oapi/user/byId", "id=1&kw=中 文&keep=%2F");
|
||||
assertEquals("/userbase/oapi/user/byId?id=1&kw=%E4%B8%AD%20%E6%96%87&keep=%2F", enc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void proxyTest_signatureVerifiesAndLoopbackReceivesIt() throws Exception {
|
||||
mvc.perform(post("/api/internal/admin/proxy-test")
|
||||
.contentType("application/json")
|
||||
.content("{\"url\":\"http://gw.example.com/userbase/oapi/user/byId?id=1&kw=中文\","
|
||||
+ "\"appKey\":\"100001\",\"appSecret\":\"" + SECRET + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value(200))
|
||||
.andExpect(jsonPath("$.target").value("userbase"))
|
||||
.andExpect(jsonPath("$.signature.appKey").value("100001"))
|
||||
.andExpect(jsonPath("$.signature.secretSource").value("INPUT"))
|
||||
.andExpect(jsonPath("$.signature.canonical").value("/oapi/user/byId?id=1&kw=中文"))
|
||||
.andExpect(jsonPath("$.body").value("{\"code\":0,\"msg\":\"\",\"data\":{\"ok\":true}}"));
|
||||
|
||||
// 回环侧收到:编码后的 query、三个签名头;body 为空(GET)
|
||||
assertEquals("/userbase/oapi/user/byId?id=1&kw=%E4%B8%AD%E6%96%87", capturedUri.get());
|
||||
assertEquals("GET", capturedMethod.get());
|
||||
assertEquals("100001", capturedHeaders.get("x-app-key"));
|
||||
String ts = capturedHeaders.get("x-timestamp");
|
||||
String sign = capturedHeaders.get("x-signature");
|
||||
assertTrue(ts != null && ts.length() >= 13, "时间戳须为毫秒级");
|
||||
// 核心断言:探针签名能被平台验签实现验回(canonical 含 query 解码排序)
|
||||
assertTrue(SignUtil.verifyRequestWithExpire("GET",
|
||||
"/oapi/user/byId?id=1&kw=中文", ts, SECRET, sign));
|
||||
}
|
||||
|
||||
@Test
|
||||
void proxyTest_secretFromRedisResolver() throws Exception {
|
||||
when(keyService.getSecret(eq("100007"))).thenReturn(SECRET);
|
||||
mvc.perform(post("/api/internal/admin/proxy-test")
|
||||
.contentType("application/json")
|
||||
.content("{\"url\":\"/notice/oapi/notice/getDict\",\"appKey\":\"100007\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value(200))
|
||||
.andExpect(jsonPath("$.signature.secretSource").value("REDIS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void proxyTest_secretUnresolvableFailsClosed() throws Exception {
|
||||
when(keyService.getSecret(eq("999999"))).thenReturn(null);
|
||||
mvc.perform(post("/api/internal/admin/proxy-test")
|
||||
.contentType("application/json")
|
||||
.content("{\"url\":\"/notice/oapi/notice/getDict\",\"appKey\":\"999999\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void proxyTest_postBodyForwarded() throws Exception {
|
||||
mvc.perform(post("/api/internal/admin/proxy-test")
|
||||
.contentType("application/json")
|
||||
.content("{\"url\":\"/flow-center/oapi/flow/apply\",\"method\":\"POST\","
|
||||
+ "\"appKey\":\"100001\",\"appSecret\":\"" + SECRET + "\","
|
||||
+ "\"body\":\"{\\\"flowId\\\":9}\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value(200));
|
||||
assertEquals("POST", capturedMethod.get());
|
||||
assertEquals("{\"flowId\":9}", new String(capturedBody.get(), StandardCharsets.UTF_8));
|
||||
assertTrue(capturedHeaders.get("content-type").startsWith("application/json"),
|
||||
"实际 content-type=" + capturedHeaders.get("content-type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void proxyTest_badPathRejected() throws Exception {
|
||||
mvc.perform(post("/api/internal/admin/proxy-test")
|
||||
.contentType("application/json")
|
||||
.content("{\"url\":\"/userbase/api/user/list\",\"appKey\":\"100001\",\"appSecret\":\"" + SECRET + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void proxyTest_shortSecretClearError() throws Exception {
|
||||
mvc.perform(post("/api/internal/admin/proxy-test")
|
||||
.contentType("application/json")
|
||||
.content("{\"url\":\"/notice/oapi/notice/getDict\",\"appKey\":\"100001\",\"appSecret\":\"short\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.yunhai.platform.gateway.auth;
|
||||
|
||||
import com.yunhai.platform.gateway.entity.OpenApiApp;
|
||||
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 应用免签注册表(C 方向 G12-8):快照只收 enabled=true 且 authMode=ANON 的行;
|
||||
* {@code '*'} 哨兵行只折算成匿名窗口时刻、不进 appKey 映射(请求头字面 {@code X-App-Key: *}
|
||||
* 不会命中具体应用);到期判定惰性(实时比对 now)——过期行即刻失效、null 到期 fail-closed。
|
||||
*/
|
||||
class AppAuthRegistryTest {
|
||||
|
||||
private OpenApiAppRepository repo;
|
||||
private AppAuthRegistry registry;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repo = mock(OpenApiAppRepository.class);
|
||||
registry = new AppAuthRegistry();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(registry, "appRepo", repo);
|
||||
}
|
||||
|
||||
private static OpenApiApp app(String appKey, String authMode, boolean enabled, LocalDateTime expireAt) {
|
||||
OpenApiApp a = new OpenApiApp();
|
||||
a.setAppKey(appKey);
|
||||
a.setAuthMode(authMode);
|
||||
a.setEnabled(enabled);
|
||||
a.setExpireAt(expireAt);
|
||||
return a;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未过期ANON行命中返回APP_ANON() {
|
||||
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
|
||||
app("100009", "ANON", true, LocalDateTime.now().plusDays(7))));
|
||||
registry.reload();
|
||||
|
||||
assertThat(registry.admit("100009")).isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
|
||||
assertThat(registry.admit(" 100009 ")).as("头值两侧空白容忍").isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 禁用行与SIGNED行不入快照() {
|
||||
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
|
||||
app("100009", "ANON", false, LocalDateTime.now().plusDays(7)), // 禁用(repo 已滤,双保险再滤)
|
||||
app("100018", "SIGNED", true, LocalDateTime.now().plusDays(7)))); // SIGNED 永不免签
|
||||
int loaded = registry.reload();
|
||||
|
||||
assertThat(loaded).as("只装具体 ANON key 行").isZero();
|
||||
assertThat(registry.admit("100009")).as("禁用 ANON 行不命中").isNull();
|
||||
assertThat(registry.admit("100018")).as("SIGNED 行不命中").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 已过期行即刻失效_惰性不查库() {
|
||||
// 行数据仍在快照里,但 expireAt 已过——admit 实时比对 now,即刻不再命中
|
||||
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
|
||||
app("100009", "ANON", true, LocalDateTime.now().minusSeconds(1))));
|
||||
registry.reload();
|
||||
|
||||
assertThat(registry.admit("100009")).as("过期 = 等价无行,落回验签").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 无到期时刻的ANON行failClosed不装载() {
|
||||
// API 不允许产生,SQL 手插的坏行也放不出
|
||||
when(repo.findByEnabledTrue()).thenReturn(Collections.singletonList(
|
||||
app("100009", "ANON", true, null)));
|
||||
registry.reload();
|
||||
|
||||
assertThat(registry.admit("100009")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 匿名窗口行放行无票据请求_且不进appKey映射() {
|
||||
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
|
||||
app(AppAuthRegistry.WINDOW_APP_KEY, "ANON", true, LocalDateTime.now().plusDays(1))));
|
||||
int loaded = registry.reload();
|
||||
|
||||
assertThat(loaded).as("'*' 行不计入具体 key 行数").isZero();
|
||||
assertThat(registry.admit(null)).as("无 X-App-Key → 窗口放行").isEqualTo(SignVerifyFilter.VERDICT_ANON);
|
||||
assertThat(registry.admit("ghost")).as("带未登记 key 亦落窗口(窗口=内部入口全部无票据请求)")
|
||||
.isEqualTo(SignVerifyFilter.VERDICT_ANON);
|
||||
assertThat(registry.admit(AppAuthRegistry.WINDOW_APP_KEY))
|
||||
.as("字面 X-App-Key: * 不是具体应用,按无 key 走窗口").isEqualTo(SignVerifyFilter.VERDICT_ANON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 窗口未开时字面星号头不命中任何应用() {
|
||||
when(repo.findByEnabledTrue()).thenReturn(Collections.emptyList());
|
||||
registry.reload();
|
||||
|
||||
assertThat(registry.admit(AppAuthRegistry.WINDOW_APP_KEY)).as("无窗口无应用 → 验签").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 窗口过期后无票据请求落回验签() {
|
||||
when(repo.findByEnabledTrue()).thenReturn(Collections.singletonList(
|
||||
app(AppAuthRegistry.WINDOW_APP_KEY, "ANON", true, LocalDateTime.now().minusMinutes(1))));
|
||||
registry.reload();
|
||||
|
||||
assertThat(registry.admit(null)).as("窗口过期 = 惰性关闭").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 具体key命中优先于窗口_但未登记key只落窗口() {
|
||||
LocalDateTime future = LocalDateTime.now().plusDays(5);
|
||||
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
|
||||
app("100009", "ANON", true, future),
|
||||
app(AppAuthRegistry.WINDOW_APP_KEY, "ANON", true, LocalDateTime.now().plusDays(1))));
|
||||
registry.reload();
|
||||
|
||||
assertThat(registry.admit("100009")).as("具体 key 免签归因 appKey")
|
||||
.isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 空白头按无key处理只看窗口() {
|
||||
when(repo.findByEnabledTrue()).thenReturn(Collections.emptyList());
|
||||
registry.reload();
|
||||
|
||||
assertThat(registry.admit("")).isNull();
|
||||
assertThat(registry.admit(" ")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reload失败沿用上一次快照() {
|
||||
when(repo.findByEnabledTrue()).thenReturn(Collections.singletonList(
|
||||
app("100009", "ANON", true, LocalDateTime.now().plusDays(7))));
|
||||
registry.reload();
|
||||
assertThat(registry.admit("100009")).isNotNull();
|
||||
|
||||
when(repo.findByEnabledTrue()).thenThrow(new RuntimeException("db down"));
|
||||
try {
|
||||
registry.reload();
|
||||
} catch (RuntimeException expected) {
|
||||
// 广播链由调用方 catch;本用例只验证异常后的快照仍可用
|
||||
}
|
||||
assertThat(registry.admit("100009")).as("异常未替换快照(stale 但准确)")
|
||||
.isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 空表快照为全量验签() {
|
||||
when(repo.findByEnabledTrue()).thenReturn(new ArrayList<>());
|
||||
registry.reload();
|
||||
|
||||
assertThat(registry.admit("100009")).isNull();
|
||||
assertThat(registry.admit(null)).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.yunhai.platform.gateway.auth;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* SignedRateLimiter 单测(能力补全 2 TDD):qps 语义(0=不限/超限/惰性补充/热调截断)、
|
||||
* 桶按 key 隔离、fail-open(D1)。纯单测,无 Spring 上下文。
|
||||
*/
|
||||
class SignedRateLimiterTest {
|
||||
|
||||
private final SignedRateLimiter limiter = new SignedRateLimiter();
|
||||
|
||||
@Test
|
||||
void qps为0或负_恒放行() {
|
||||
assertThat(limiter.tryAcquire("k1", 0)).isTrue();
|
||||
assertThat(limiter.tryAcquire("k1", 0)).isTrue();
|
||||
assertThat(limiter.tryAcquire("k1", -5)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void qps为2_连续第3次拒绝() {
|
||||
assertThat(limiter.tryAcquire("k2", 2)).isTrue();
|
||||
assertThat(limiter.tryAcquire("k2", 2)).isTrue();
|
||||
assertThat(limiter.tryAcquire("k2", 2)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 拒绝后按qps惰性补充_可恢复() throws InterruptedException {
|
||||
assertThat(limiter.tryAcquire("k3", 1)).isTrue();
|
||||
assertThat(limiter.tryAcquire("k3", 1)).isFalse();
|
||||
Thread.sleep(1100); // qps=1 → 1 秒补 1 个令牌
|
||||
assertThat(limiter.tryAcquire("k3", 1)).as("惰性补充后恢复").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void qps调小_存量令牌截断到新容量() {
|
||||
assertThat(limiter.tryAcquire("k4", 100)).isTrue(); // 新桶满容 = qps(1 秒突发)
|
||||
// 同一桶改按 qps=1 判定(治理页热调,D7):存量令牌须截断,不得沿用旧容量连放
|
||||
assertThat(limiter.tryAcquire("k4", 1)).isTrue();
|
||||
assertThat(limiter.tryAcquire("k4", 1)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 不同key桶各自独立() {
|
||||
assertThat(limiter.tryAcquire("appA@demo /oapi/x GET", 1)).isTrue();
|
||||
assertThat(limiter.tryAcquire("appB@demo /oapi/x GET", 1)).as("不同调用方互不挤占").isTrue();
|
||||
assertThat(limiter.tryAcquire("appA@demo /oapi/x GET", 1)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 限流器内部异常_failOpen放行() {
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
Cache broken = mock(Cache.class);
|
||||
when(broken.get(any(), any())).thenThrow(new IllegalStateException("boom"));
|
||||
ReflectionTestUtils.setField(limiter, "buckets", broken);
|
||||
assertThat(limiter.tryAcquire("k5", 10)).as("fail-open(D1):限流器故障不断主链路").isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.yunhai.platform.gateway.filter;
|
||||
|
||||
import com.yunhai.platform.gateway.forward.StreamingForwarder;
|
||||
import com.yunhai.platform.gateway.route.RouteSpec;
|
||||
import com.yunhai.platform.gateway.route.TargetRouter;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import java.lang.reflect.Constructor;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* DispatchFilter 单测(能力补全 3 TDD):per-route 超时/体积取 spec 透传转发器、
|
||||
* 缺 spec 走默认、值 ≤0 防御回落(D6)。{@code TargetRouter.Routing} 构造包级,
|
||||
* 反射构建测试夹具。纯单测,mock 转发器只验参不真转。
|
||||
*/
|
||||
class DispatchFilterTest {
|
||||
|
||||
private DispatchFilter filter;
|
||||
private StreamingForwarder forwarder;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
forwarder = mock(StreamingForwarder.class);
|
||||
filter = new DispatchFilter();
|
||||
filter.setForwarder(forwarder);
|
||||
}
|
||||
|
||||
/** Routing 构造包级(filter 包不可见),反射构建 */
|
||||
private static TargetRouter.Routing routing(String forwardUrl) throws Exception {
|
||||
Constructor<TargetRouter.Routing> c = TargetRouter.Routing.class
|
||||
.getDeclaredConstructor(String.class, String.class, String.class);
|
||||
c.setAccessible(true);
|
||||
return c.newInstance("demo", "/oapi/hello", forwardUrl);
|
||||
}
|
||||
|
||||
private MockHttpServletRequest request(TargetRouter.Routing routing, RouteSpec spec) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/demo/oapi/hello");
|
||||
req.setAttribute(RoutingFilter.ATTR_ROUTING, routing);
|
||||
req.setAttribute(RoutingFilter.ATTR_ROUTE_SPEC, spec);
|
||||
return req;
|
||||
}
|
||||
|
||||
private ArgumentCaptor<Long> forwardAndCaptureBodyLimit(MockHttpServletRequest req) throws Exception {
|
||||
filter.doFilter(req, new MockHttpServletResponse(), mock(FilterChain.class));
|
||||
ArgumentCaptor<Long> limit = ArgumentCaptor.forClass(Long.class);
|
||||
verify(forwarder).forward(any(), any(), anyString(), anyInt(), limit.capture());
|
||||
return limit;
|
||||
}
|
||||
|
||||
@Test
|
||||
void spec超时与体积_透传转发器() throws Exception {
|
||||
RouteSpec spec = new RouteSpec("demo /oapi/hello GET", "INTERNAL", "SIGNED",
|
||||
0, "CALLER", 0, 5000, 2);
|
||||
MockHttpServletRequest req = request(routing("http://backend/demo/oapi/hello"), spec);
|
||||
ArgumentCaptor<Long> limit = forwardAndCaptureBodyLimit(req);
|
||||
|
||||
verify(forwarder).forward(any(), any(), eq("http://backend/demo/oapi/hello"), eq(5000), limit.capture());
|
||||
assertThat(limit.getValue()).as("bodyLimit(mb) 换算字节透传").isEqualTo(2L * 1024 * 1024);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 缺spec_用转发器默认() throws Exception {
|
||||
MockHttpServletRequest req = request(routing("http://backend/demo/oapi/hello"), null);
|
||||
ArgumentCaptor<Long> limit = forwardAndCaptureBodyLimit(req);
|
||||
|
||||
verify(forwarder).forward(any(), any(), anyString(),
|
||||
eq(StreamingForwarder.DEFAULT_READ_TIMEOUT_MS), limit.capture());
|
||||
assertThat(limit.getValue()).isEqualTo(StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void spec值非正_防御回落默认() throws Exception {
|
||||
RouteSpec spec = new RouteSpec("demo /oapi/hello GET", "INTERNAL", "SIGNED",
|
||||
0, "CALLER", 0, 0, 0);
|
||||
MockHttpServletRequest req = request(routing("http://backend/demo/oapi/hello"), spec);
|
||||
ArgumentCaptor<Long> limit = forwardAndCaptureBodyLimit(req);
|
||||
|
||||
verify(forwarder).forward(any(), any(), anyString(),
|
||||
eq(StreamingForwarder.DEFAULT_READ_TIMEOUT_MS), limit.capture());
|
||||
assertThat(limit.getValue()).as("D6:≤0 回落转发器默认").isEqualTo(StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 缺routing属性_裸404不转() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/demo/oapi/hello");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
filter.doFilter(req, res, mock(FilterChain.class));
|
||||
assertThat(res.getStatus()).as("异常路径按 404 收口").isEqualTo(404);
|
||||
org.mockito.Mockito.verifyNoInteractions(forwarder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.yunhai.platform.gateway.filter;
|
||||
|
||||
import com.yunhai.platform.gateway.auth.SignedRateLimiter;
|
||||
import com.yunhai.platform.gateway.route.RouteSpec;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* RateLimitFilter 单测(能力补全 2 TDD):判定对象(PASS/APP_ANON 判、FAIL_OBSERVED/ANON 不判)、
|
||||
* 裸 429 与 observe 正交(本环不读 observe,超限即拒)、qps=0 不限、自身端点旁路。纯单测。
|
||||
*/
|
||||
class RateLimitFilterTest {
|
||||
|
||||
private RateLimitFilter filter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
filter = new RateLimitFilter();
|
||||
filter.setRateLimiter(new SignedRateLimiter());
|
||||
}
|
||||
|
||||
private MockHttpServletRequest request(String verdict, String appKey, RouteSpec spec) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/demo/oapi/hello");
|
||||
req.setRequestURI("/demo/oapi/hello");
|
||||
if (verdict != null) {
|
||||
req.setAttribute(SignVerifyFilter.ATTR_VERDICT, verdict);
|
||||
}
|
||||
if (appKey != null) {
|
||||
req.setAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY, appKey);
|
||||
}
|
||||
if (spec != null) {
|
||||
req.setAttribute(RoutingFilter.ATTR_ROUTE_SPEC, spec);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
private static RouteSpec spec(int qps) {
|
||||
return new RouteSpec("demo /oapi/hello GET", "INTERNAL", "SIGNED", 0, "CALLER", qps, 30000, 10);
|
||||
}
|
||||
|
||||
private MockHttpServletResponse doFilter(MockHttpServletRequest req, MockFilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
filter.doFilter(req, res, chain);
|
||||
return res;
|
||||
}
|
||||
|
||||
@Test
|
||||
void pass超限_裸429无响应体_observe无关() throws Exception {
|
||||
// 本环不读 observe:验签已过的 PASS 流量超限即拒(与 403 同口径正交),verdict/caller 保持第 3 环产出
|
||||
MockFilterChain first = new MockFilterChain();
|
||||
assertThat(doFilter(request(SignVerifyFilter.VERDICT_PASS, "100617", spec(1)), first).getStatus())
|
||||
.as("第 1 次限内放行").isEqualTo(200);
|
||||
assertThat(first.getRequest()).isNotNull();
|
||||
|
||||
MockFilterChain second = new MockFilterChain();
|
||||
MockHttpServletResponse res = doFilter(request(SignVerifyFilter.VERDICT_PASS, "100617", spec(1)), second);
|
||||
assertThat(res.getStatus()).as("第 2 次超限裸 429").isEqualTo(429);
|
||||
assertThat(res.getContentAsByteArray()).as("F5 契约:裸码无体").isEmpty();
|
||||
assertThat(second.getRequest()).as("被限请求不进转发链").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pass但qps为0_不限() throws Exception {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
doFilter(request(SignVerifyFilter.VERDICT_PASS, "100617", spec(0)), chain);
|
||||
assertThat(chain.getRequest()).as("存量契约 0=不限,第 " + (i + 1) + " 次仍放行").isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failObserved不判_超限也放行() throws Exception {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
doFilter(request(SignVerifyFilter.VERDICT_FAIL_OBSERVED, "100617", spec(1)), chain);
|
||||
assertThat(chain.getRequest()).as("observe 过渡态不判(D3),第 " + (i + 1) + " 次仍放行").isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void appAnon与pass同口径_超限429() throws Exception {
|
||||
MockFilterChain first = new MockFilterChain();
|
||||
assertThat(doFilter(request(SignVerifyFilter.VERDICT_APP_ANON, "100617", spec(1)), first).getStatus())
|
||||
.isEqualTo(200);
|
||||
MockFilterChain second = new MockFilterChain();
|
||||
assertThat(doFilter(request(SignVerifyFilter.VERDICT_APP_ANON, "100617", spec(1)), second).getStatus())
|
||||
.as("应用级免签同样限").isEqualTo(429);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anon不判_路由级匿名走自己的限流路径() throws Exception {
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
doFilter(request(SignVerifyFilter.VERDICT_ANON, null, spec(1)), chain);
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 身份缺失或spec缺失_放行不判() throws Exception {
|
||||
MockFilterChain noKey = new MockFilterChain();
|
||||
doFilter(request(SignVerifyFilter.VERDICT_PASS, null, spec(1)), noKey);
|
||||
assertThat(noKey.getRequest()).as("appKey 缺失(异常路径)不判").isNotNull();
|
||||
|
||||
MockFilterChain noSpec = new MockFilterChain();
|
||||
doFilter(request(SignVerifyFilter.VERDICT_PASS, "100617", null), noSpec);
|
||||
assertThat(noSpec.getRequest()).as("spec 缺失(异常路径)不判").isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 网关自身端点旁路() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/internal/routes/refresh");
|
||||
req.setRequestURI("/api/internal/routes/refresh");
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
doFilter(req, chain);
|
||||
assertThat(chain.getRequest()).as("自身端点不经 oapi 判定(同 Dispatch 旁路口径)").isNotNull();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yunhai.platform.gateway.filter;
|
||||
|
||||
import com.yunhai.platform.gateway.auth.AnonRateLimiter;
|
||||
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
|
||||
import com.yunhai.platform.gateway.auth.EntryDetector;
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import com.yunhai.platform.gateway.route.GatewayProperties;
|
||||
@@ -33,9 +34,9 @@ class SignVerifyFilterEntryTest {
|
||||
|
||||
private static final String SECRET = "sec-entry-0123456789abcdef";
|
||||
|
||||
private static final RouteSpec INTERNAL_SIGNED = new RouteSpec("INTERNAL", "SIGNED", 0, "CALLER", 0);
|
||||
private static final RouteSpec EXTERNAL_SIGNED = new RouteSpec("EXTERNAL", "SIGNED", 0, "CALLER", 0);
|
||||
private static final RouteSpec EXTERNAL_ANON = new RouteSpec("EXTERNAL", "ANON", 0, "CALLER", 0);
|
||||
private static final RouteSpec INTERNAL_SIGNED = new RouteSpec("?", "INTERNAL", "SIGNED", 0, "CALLER", 0, 30000, 10);
|
||||
private static final RouteSpec EXTERNAL_SIGNED = new RouteSpec("?", "EXTERNAL", "SIGNED", 0, "CALLER", 0, 30000, 10);
|
||||
private static final RouteSpec EXTERNAL_ANON = new RouteSpec("?", "EXTERNAL", "ANON", 0, "CALLER", 0, 30000, 10);
|
||||
|
||||
private static final AnonRateLimiter ALLOW_LIMITER = (ip, qps) -> AnonRateLimiter.Decision.ALLOW;
|
||||
private static final AnonRateLimiter EXCEED_LIMITER = (ip, qps) -> AnonRateLimiter.Decision.EXCEEDED;
|
||||
@@ -193,4 +194,37 @@ class SignVerifyFilterEntryTest {
|
||||
assertThat(res.getStatus()).as("伪造标记无效:走内部口径验签 → 401").isEqualTo(401);
|
||||
assertThat(chain.getRequest()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 应用免签不外溢到外部入口_窗口开着仍须验签() throws Exception {
|
||||
// C 方向 D3:应用免签永不外溢——即使注册表对任意头都放行(窗口开/ANON 行在),
|
||||
// 外部入口 EXTERNAL×SIGNED 仍走验签分支,无签名裸 401
|
||||
SignVerifyFilter f = filter(false, true, ALLOW_LIMITER);
|
||||
AppAuthRegistry registry = mock(AppAuthRegistry.class);
|
||||
when(registry.admit(org.mockito.ArgumentMatchers.any())).thenReturn(SignVerifyFilter.VERDICT_APP_ANON);
|
||||
f.setAppAuthRegistry(registry);
|
||||
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request(EXTERNAL_SIGNED, EntryDetector.ENTRY_EXTERNAL);
|
||||
MockHttpServletResponse res = run(f, req, chain);
|
||||
|
||||
assertThat(res.getStatus()).as("外部入口不查应用免签").isEqualTo(401);
|
||||
assertThat(chain.getRequest()).as("链条中断").isNull();
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 外部入口EXTERNAL_SIGNED_合法签名照旧PASS_与免签注册表无关() throws Exception {
|
||||
SignVerifyFilter f = filter(false, true, ALLOW_LIMITER);
|
||||
AppAuthRegistry registry = mock(AppAuthRegistry.class);
|
||||
when(registry.admit(org.mockito.ArgumentMatchers.any())).thenReturn(SignVerifyFilter.VERDICT_APP_ANON);
|
||||
f.setAppAuthRegistry(registry);
|
||||
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = signedExternalRequest(EXTERNAL_SIGNED);
|
||||
MockHttpServletResponse res = run(f, req, chain);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).as("外部只认验签 PASS").isEqualTo(SignVerifyFilter.VERDICT_PASS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.yunhai.platform.gateway.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import com.yunhai.platform.gateway.route.GatewayProperties;
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
@@ -11,6 +12,7 @@ import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -169,4 +171,90 @@ class SignVerifyFilterTest {
|
||||
.isEqualTo(expectedSign);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 应用级免签(C 方向 G12-1~4):内部入口 admit 命中即放行,未命中落回验签 ============ */
|
||||
|
||||
/** 挂应用免签注册表的过滤器;admit 返回值由用例控制 */
|
||||
private SignVerifyFilter filterWithRegistry(String admitVerdict) {
|
||||
SignVerifyFilter f = filter(false);
|
||||
AppAuthRegistry registry = mock(AppAuthRegistry.class);
|
||||
when(registry.admit(org.mockito.ArgumentMatchers.any())).thenReturn(admitVerdict);
|
||||
f.setAppAuthRegistry(registry);
|
||||
return f;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 内部ANON应用凭key免签放行且注入身份() throws Exception {
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "100009", null, null);
|
||||
MockHttpServletResponse res = run(filterWithRegistry(SignVerifyFilter.VERDICT_APP_ANON), req, chain);
|
||||
|
||||
assertThat(res.getStatus()).as("免 X-Timestamp/X-Signature 两头直接放行").isEqualTo(200);
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("台账归因与身份注入依据").isEqualTo("100009");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 内部匿名窗口无票据放行且不注入身份() throws Exception {
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, null, null, null);
|
||||
MockHttpServletResponse res = run(filterWithRegistry(SignVerifyFilter.VERDICT_ANON), req, chain);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_ANON);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("匿名就是匿名:不注入").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 内部ANON行过期落回验签observe下FAIL_OBSERVED() throws Exception {
|
||||
// admit 返回 null(惰性到期,等同无行)→ 原验签行为一字不变
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "100009", null, null);
|
||||
SignVerifyFilter f = filter(true);
|
||||
AppAuthRegistry registry = mock(AppAuthRegistry.class);
|
||||
when(registry.admit(org.mockito.ArgumentMatchers.any())).thenReturn(null);
|
||||
f.setAppAuthRegistry(registry);
|
||||
MockHttpServletResponse res = run(f, req, chain);
|
||||
|
||||
assertThat(res.getStatus()).as("observe=true:验签失败仍只记不拦").isEqualTo(200);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL_OBSERVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 内部ANON行过期非observe下裸401() throws Exception {
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "100009", null, null);
|
||||
MockHttpServletResponse res = run(filterWithRegistry(null), req, chain);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
assertThat(chain.getRequest()).as("链条中断").isNull();
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 内部无行SIGNED禁用_原验签行为不变() throws Exception {
|
||||
// 回归:注册表恒 null(表空/无行/SIGNED/禁用同口径)→ 与改造前判定链完全一致
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, sign);
|
||||
MockHttpServletResponse res = run(filterWithRegistry(null), req, chain);
|
||||
|
||||
assertThat(res.getStatus()).as("合法签名照旧 PASS").isEqualTo(200);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).isEqualTo("app1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 注册表未装配时落回验签() throws Exception {
|
||||
// 单测/异常装配形态:registry 为 null 绝不能 NPE,fail-closed 走原验签
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, null, null, null);
|
||||
MockHttpServletResponse res = run(filter(false), req, chain);
|
||||
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ import java.util.concurrent.Executors;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 身份头注入 X-Caller-App-Key 四用例(T1.4 TDD):PASS 注入且值 = 验签 appKey;
|
||||
* FAIL(observe)=FAIL_OBSERVED 不注入;ANON 路由不注入(匿名就是匿名);
|
||||
* 客户端伪造头先剥后注入——假后端收到的终值 = 网关注入值(唯一来源,顺序契约)。
|
||||
* 身份头注入 X-Caller-App-Key 用例(T1.4 TDD + C 方向 G12-6):PASS 注入且值 = 验签 appKey;
|
||||
* APP_ANON(应用级免签)注入且值 = 自报 appKey;FAIL(observe)=FAIL_OBSERVED 不注入;
|
||||
* ANON 路由/匿名窗口不注入(匿名就是匿名);客户端伪造头先剥后注入——假后端收到的终值 =
|
||||
* 网关注入值(唯一来源,顺序契约)。
|
||||
*/
|
||||
class CallerHeaderTest {
|
||||
|
||||
@@ -93,6 +94,19 @@ class CallerHeaderTest {
|
||||
assertThat(headers.get("x-caller-app-key")).isEqualTo("100617");
|
||||
}
|
||||
|
||||
@Test
|
||||
void APP_ANON判定注入且值等于自报appKey() throws Exception {
|
||||
// 应用级免签(C 方向):自报未验身份也注入——下游凭头归因,背书弱于 PASS(O2:暂不区分)
|
||||
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_APP_ANON, "100009", null);
|
||||
assertThat(headers.get("x-caller-app-key")).isEqualTo("100009");
|
||||
}
|
||||
|
||||
@Test
|
||||
void APP_ANON_伪造头先剥后注入() throws Exception {
|
||||
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_APP_ANON, "100009", "hack");
|
||||
assertThat(headers.get("x-caller-app-key")).as("唯一合法来源是网关").isEqualTo("100009");
|
||||
}
|
||||
|
||||
@Test
|
||||
void FAIL_OBSERVED不注入() throws Exception {
|
||||
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_FAIL_OBSERVED, null, null);
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.yunhai.platform.gateway.log;
|
||||
|
||||
import com.yunhai.platform.gateway.entity.OpenApiLog;
|
||||
import com.yunhai.platform.gateway.entity.OpenApiStatDaily;
|
||||
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiStatDailyRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -104,4 +106,48 @@ class AccessLogServiceTest {
|
||||
assertThat(svc.queueSize()).isEqualTo(2);
|
||||
verify(logRepo, never()).saveAll(any());
|
||||
}
|
||||
|
||||
/* ============ caller 归因口径(C 方向 G12-7):APP_ANON → appKey;ANON → anonymous ============ */
|
||||
|
||||
private MockHttpServletRequest reqWithVerdict(String verdict, String authAppKey, String headerKey) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/x");
|
||||
req.setAttribute(SignVerifyFilter.ATTR_VERDICT, verdict);
|
||||
if (authAppKey != null) {
|
||||
req.setAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY, authAppKey);
|
||||
}
|
||||
if (headerKey != null) {
|
||||
req.addHeader("X-App-Key", headerKey);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void APP_ANON归因到自报appKey而非anonymous() {
|
||||
String caller = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
|
||||
reqWithVerdict(SignVerifyFilter.VERDICT_APP_ANON, "100009", "100009"));
|
||||
assertThat(caller).as("应用级免签照常归因").isEqualTo("100009");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ANON判定归因anonymous_即使带了未登记key() {
|
||||
// 匿名窗口放行的请求可能带未登记 key,但 verdict=ANON 就是匿名口径
|
||||
String caller = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
|
||||
reqWithVerdict(SignVerifyFilter.VERDICT_ANON, null, "ghost"));
|
||||
assertThat(caller).isEqualTo(AccessLogService.CALLER_ANONYMOUS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void PASS归因验签appKey_FAIL回退取头() {
|
||||
String pass = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
|
||||
reqWithVerdict(SignVerifyFilter.VERDICT_PASS, "100617", "100617"));
|
||||
assertThat(pass).isEqualTo("100617");
|
||||
|
||||
String fail = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
|
||||
reqWithVerdict(SignVerifyFilter.VERDICT_FAIL, null, "100617"));
|
||||
assertThat(fail).as("401 有头尽力记").isEqualTo("100617");
|
||||
|
||||
String none = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
|
||||
reqWithVerdict(SignVerifyFilter.VERDICT_FAIL, null, null));
|
||||
assertThat(none).as("401 无 appKey = NULL").isNull();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -36,7 +36,7 @@ class RouteChangeBroadcasterWiringTest {
|
||||
|
||||
private static final String MAIN_TEMPLATE_BEAN = "stringRedisTemplate";
|
||||
|
||||
/** 复现环境:注册两个同类型模板 + 一个 RouteRegistry */
|
||||
/** 复现环境:注册两个同类型模板 + 一个 RouteRegistry + 一个 AppAuthRegistry(广播器 onMessage 重载对象) */
|
||||
private DefaultListableBeanFactory twoTemplatesFactory() {
|
||||
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
|
||||
factory.setAutowireCandidateResolver(new QualifierAnnotationAutowireCandidateResolver());
|
||||
@@ -45,6 +45,7 @@ class RouteChangeBroadcasterWiringTest {
|
||||
factory.registerSingleton(SIGN_TEMPLATE_BEAN,
|
||||
new StringRedisTemplate(mock(RedisConnectionFactory.class)));
|
||||
factory.registerSingleton("routeRegistry", mock(RouteRegistry.class));
|
||||
factory.registerSingleton("appAuthRegistry", mock(com.yunhai.platform.gateway.auth.AppAuthRegistry.class));
|
||||
return factory;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,23 @@ class RouteRegistryTest {
|
||||
assertThat(spec.rateLimitQps).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 行身份与转发参数_按行提炼() {
|
||||
// 能力补全 2+3:routeKey=行身份(限流桶维度),timeout/bodyLimit=转发参数
|
||||
OpenApiRoute row = route("usermsg", "/oapi/message/list", "*", "ANON", 30);
|
||||
row.setRateLimitQps(7);
|
||||
row.setTimeoutMs(5000);
|
||||
row.setRequestBodyLimitMb(2);
|
||||
when(routeRepo.findByEnabledTrue()).thenReturn(new ArrayList<>(Arrays.asList(row)));
|
||||
registry.reload();
|
||||
|
||||
RouteSpec spec = registry.resolve("usermsg", "/oapi/message/list", "GET");
|
||||
assertThat(spec.routeKey).isEqualTo("usermsg /oapi/message/list *");
|
||||
assertThat(spec.rateLimitQps).isEqualTo(7);
|
||||
assertThat(spec.timeoutMs).isEqualTo(5000);
|
||||
assertThat(spec.requestBodyLimitMb).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 前缀通配命中() {
|
||||
RouteSpec spec = registry.resolve("usermsg", "/oapi/message/detail", "POST");
|
||||
@@ -70,13 +87,16 @@ class RouteRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未注册路径取最严默认_五字段全断言() {
|
||||
void 未注册路径取最严默认_八字段全断言() {
|
||||
RouteSpec spec = registry.resolve("usermsg", "/oapi/unknown/path", "GET");
|
||||
assertThat(spec.routeKey).as("routeKey(DEFAULT 为 \"?\",qps=0 不会进限流桶)").isEqualTo("?");
|
||||
assertThat(spec.visibility).as("visibility").isEqualTo("INTERNAL");
|
||||
assertThat(spec.authMode).as("authMode").isEqualTo("SIGNED");
|
||||
assertThat(spec.cacheTtlSeconds).as("cacheTtl").isZero();
|
||||
assertThat(spec.cacheScope).as("cacheScope").isEqualTo("CALLER");
|
||||
assertThat(spec.rateLimitQps).as("qps").isZero();
|
||||
assertThat(spec.timeoutMs).as("timeoutMs").isEqualTo(30000);
|
||||
assertThat(spec.requestBodyLimitMb).as("bodyLimit").isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -6,11 +6,12 @@ spring:
|
||||
password:
|
||||
sql:
|
||||
init:
|
||||
# 直接跑交付 DDL(docs/sql/01_schema.sql + 03_target.sql)——round-trip 钉死「DDL ↔ 实体」一致
|
||||
# 直接跑交付 DDL(docs/sql/01_schema.sql + 03_target.sql + 04_app.sql)——round-trip 钉死「DDL ↔ 实体」一致
|
||||
mode: always
|
||||
schema-locations:
|
||||
- file:docs/sql/01_schema.sql
|
||||
- file:docs/sql/03_target.sql
|
||||
- file:docs/sql/04_app.sql
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
|
||||
Reference in New Issue
Block a user