T1.6: 观察模式开关 + Filter 链组装(链序定型 台账0→Sanitize1→Routing2→SignVerify3→身份注入4(转发器内)→Dispatch5;/api/internal/** 三环旁路直达 controller;observe=true 坏签名放行、false 裸401 集成对照;400/404 协议层错误不受 observe 影响;关掉 starter AppAuthFilter(正确键 yh.app-auth.enabled=false)避免抢链首误 401)
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package com.yunhai.platform.gateway.chain;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import com.yunhai.platform.gateway.entity.OpenApiLog;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* T1.6 链序集成测试(observe=true 默认):Sanitize(0/1) → Routing(2) → SignVerify(3) →
|
||||
* 身份注入(4,转发器内) → Dispatch(5) 全链走通——假后端收到注入的 X-Caller-App-Key、
|
||||
* 伪造值被剥、响应原样透传、台账异步入库;observe 语义边界:坏签名放行、
|
||||
* 但协议层错误(400/404)不受 observe 影响永远拦;/api/internal/** 自身端点不被链拦截。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class GatewayChainTest {
|
||||
|
||||
private static final String SECRET = "sec-chain-0123456789abcdef";
|
||||
private static final HttpServer BACKEND;
|
||||
private static final int BACKEND_PORT;
|
||||
static final AtomicReference<String> CALLER_SEEN = new AtomicReference<>();
|
||||
static final AtomicReference<String> URI_SEEN = new AtomicReference<>();
|
||||
|
||||
static {
|
||||
try {
|
||||
BACKEND = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
BACKEND.setExecutor(Executors.newFixedThreadPool(4));
|
||||
BACKEND.createContext("/", exchange -> {
|
||||
CALLER_SEEN.set(exchange.getRequestHeaders().getFirst("X-Caller-App-Key"));
|
||||
URI_SEEN.set(exchange.getRequestURI().toString());
|
||||
byte[] body = "pong".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("X-Upstream", "v1");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (OutputStream out = exchange.getResponseBody()) {
|
||||
out.write(body);
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
BACKEND.start();
|
||||
BACKEND_PORT = BACKEND.getAddress().getPort();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void routes(DynamicPropertyRegistry registry) {
|
||||
registry.add("yh.gateway.routes.demo", () -> "http://127.0.0.1:" + BACKEND_PORT);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
TestRestTemplate rest;
|
||||
@Value("${local.server.port}")
|
||||
int port;
|
||||
@Autowired
|
||||
OpenApiLogRepository logRepository;
|
||||
@MockBean
|
||||
GatewayKeyService keyService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logRepository.deleteAll();
|
||||
CALLER_SEEN.set(null);
|
||||
URI_SEEN.set(null);
|
||||
when(keyService.getSecret("100617")).thenReturn(SECRET);
|
||||
}
|
||||
|
||||
private HttpHeaders signedHeaders() {
|
||||
return signedHeaders("/oapi/hello");
|
||||
}
|
||||
|
||||
/** 客户端签名口径 = 剥掉 target 段后的 path[?query](F1),故 query 必须进 canonical */
|
||||
private HttpHeaders signedHeaders(String pathToSign) {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
HttpHeaders h = new HttpHeaders();
|
||||
h.add("X-App-Key", "100617");
|
||||
h.add("X-Timestamp", ts);
|
||||
h.add("X-Signature", SignUtil.signRequest("GET", pathToSign, ts, SECRET));
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 台账异步落库(500ms 周期 + 异步 complete),轮询等待 */
|
||||
private List<OpenApiLog> awaitLedger() throws InterruptedException {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
List<OpenApiLog> all = logRepository.findAll();
|
||||
if (!all.isEmpty()) {
|
||||
return all;
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
return logRepository.findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 全链走通_身份注入_响应透传_台账入库() throws Exception {
|
||||
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
|
||||
new HttpEntity<>(signedHeaders()), String.class);
|
||||
|
||||
assertThat(resp.getStatusCodeValue()).isEqualTo(200);
|
||||
assertThat(resp.getBody()).as("提供方响应体原样透传").isEqualTo("pong");
|
||||
assertThat(resp.getHeaders().getFirst("X-Upstream")).as("提供方响应头原样透传").isEqualTo("v1");
|
||||
assertThat(URI_SEEN.get()).as("换主机不换路径").isEqualTo("/demo/oapi/hello");
|
||||
assertThat(CALLER_SEEN.get()).as("链序终值 = 网关注入的验签 appKey").isEqualTo("100617");
|
||||
|
||||
List<OpenApiLog> ledger = awaitLedger();
|
||||
assertThat(ledger).as("台账入库").isNotEmpty();
|
||||
OpenApiLog row = ledger.get(0);
|
||||
assertThat(row.getTargetCode()).isEqualTo("demo");
|
||||
assertThat(row.getPath()).isEqualTo("/oapi/hello");
|
||||
assertThat(row.getCallerAppKey()).isEqualTo("100617");
|
||||
assertThat(row.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 伪造身份头被剥_终值为网关注入() throws Exception {
|
||||
HttpHeaders h = signedHeaders();
|
||||
h.add("X-Caller-App-Key", "hack");
|
||||
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
|
||||
new HttpEntity<>(h), String.class);
|
||||
assertThat(resp.getStatusCodeValue()).isEqualTo(200);
|
||||
assertThat(CALLER_SEEN.get()).as("先剥后注入:链上终值唯一").isEqualTo("100617");
|
||||
}
|
||||
|
||||
@Test
|
||||
void observe为true坏签名放行且台账有记录() throws Exception {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
HttpHeaders h = new HttpHeaders();
|
||||
h.add("X-App-Key", "100617");
|
||||
h.add("X-Timestamp", ts);
|
||||
h.add("X-Signature", "deadbeef");
|
||||
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
|
||||
new HttpEntity<>(h), String.class);
|
||||
|
||||
assertThat(resp.getStatusCodeValue()).as("R4:P1 只观察不拦").isEqualTo(200);
|
||||
assertThat(CALLER_SEEN.get()).as("FAIL_OBSERVED 不注入身份").isNull();
|
||||
List<OpenApiLog> ledger = awaitLedger();
|
||||
assertThat(ledger).as("坏签名也进台账(caller 尽力记)").isNotEmpty();
|
||||
assertThat(ledger.get(0).getCallerAppKey()).isEqualTo("100617");
|
||||
}
|
||||
|
||||
@Test
|
||||
void observe不影响协议层错误_400与404永远拦() throws Exception {
|
||||
// 原始 URI 直发(不经客户端归一化):编码形态 %2e%2e 解码后是 ..,Sanitize 必裸 400
|
||||
URI traversal = new URI("http://127.0.0.1:" + port + "/demo/oapi/a/%2e%2e/b");
|
||||
assertThat(rest.getForEntity(traversal, String.class).getStatusCodeValue())
|
||||
.as("Sanitize 400 不受 observe 影响").isEqualTo(400);
|
||||
assertThat(rest.getForEntity("/noway/oapi/x", String.class).getStatusCodeValue())
|
||||
.as("未知 target 404 不受 observe 影响").isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 网关自身internal端点不被链拦_refresh可用() {
|
||||
ResponseEntity<String> resp = rest.postForEntity("/api/internal/routes/refresh", null, String.class);
|
||||
assertThat(resp.getStatusCodeValue()).as("自身端点走 controller,不进 oapi 链").isEqualTo(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.yunhai.platform.gateway.chain;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import com.yunhai.platform.gateway.entity.OpenApiLog;
|
||||
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* T1.6 observe=false(收口形态)对照用例:坏签名不再只记,而是<b>裸 401</b> 拦下、
|
||||
* 请求不到提供方;台账仍记录该次判定(status=401,caller 尽力记 appKey)。
|
||||
* 与 {@link GatewayChainTest} 分属两个 Spring context(observe 属性不同)。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "yh.gateway.observe=false")
|
||||
@ActiveProfiles("test")
|
||||
class GatewayEnforceChainTest {
|
||||
|
||||
private static final String SECRET = "sec-enforce-0123456789abc";
|
||||
private static final HttpServer BACKEND;
|
||||
private static final int BACKEND_PORT;
|
||||
static final AtomicReference<String> CALLER_SEEN = new AtomicReference<>();
|
||||
|
||||
static {
|
||||
try {
|
||||
BACKEND = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
BACKEND.setExecutor(Executors.newFixedThreadPool(2));
|
||||
BACKEND.createContext("/", exchange -> {
|
||||
CALLER_SEEN.set(exchange.getRequestHeaders().getFirst("X-Caller-App-Key"));
|
||||
byte[] body = "pong".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (OutputStream out = exchange.getResponseBody()) {
|
||||
out.write(body);
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
BACKEND.start();
|
||||
BACKEND_PORT = BACKEND.getAddress().getPort();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void routes(DynamicPropertyRegistry registry) {
|
||||
registry.add("yh.gateway.routes.demo", () -> "http://127.0.0.1:" + BACKEND_PORT);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
TestRestTemplate rest;
|
||||
@Autowired
|
||||
OpenApiLogRepository logRepository;
|
||||
@MockBean
|
||||
GatewayKeyService keyService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logRepository.deleteAll();
|
||||
CALLER_SEEN.set(null);
|
||||
when(keyService.getSecret("100617")).thenReturn(SECRET);
|
||||
}
|
||||
|
||||
private List<OpenApiLog> awaitLedger() throws InterruptedException {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
List<OpenApiLog> all = logRepository.findAll();
|
||||
if (!all.isEmpty()) {
|
||||
return all;
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
return logRepository.findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 坏签名observe为false裸401且不到提供方() throws Exception {
|
||||
HttpHeaders h = new HttpHeaders();
|
||||
h.add("X-App-Key", "100617");
|
||||
h.add("X-Timestamp", String.valueOf(System.currentTimeMillis()));
|
||||
h.add("X-Signature", "deadbeef");
|
||||
|
||||
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
|
||||
new HttpEntity<>(h), String.class);
|
||||
|
||||
assertThat(resp.getStatusCodeValue()).as("收口形态:裸 401").isEqualTo(401);
|
||||
assertThat(resp.getBody()).as("裸码无信封").isNullOrEmpty();
|
||||
assertThat(CALLER_SEEN.get()).as("请求不得送达提供方").isNull();
|
||||
|
||||
List<OpenApiLog> ledger = awaitLedger();
|
||||
assertThat(ledger).as("拦截同样进台账").isNotEmpty();
|
||||
assertThat(ledger.get(0).getStatus()).isEqualTo(401);
|
||||
assertThat(ledger.get(0).getCallerAppKey()).isEqualTo("100617");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 好签名observe为false正常放行() throws Exception {
|
||||
HttpHeaders h = new HttpHeaders();
|
||||
h.add("X-App-Key", "100617");
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
h.add("X-Timestamp", ts);
|
||||
h.add("X-Signature", SignUtil.signRequest("GET", "/oapi/hello", ts, SECRET));
|
||||
|
||||
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
|
||||
new HttpEntity<>(h), String.class);
|
||||
|
||||
assertThat(resp.getStatusCodeValue()).isEqualTo(200);
|
||||
assertThat(CALLER_SEEN.get()).isEqualTo("100617");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user