T1.3: StreamingForwarder 流式转发(AsyncContext+HttpClient 零缓冲透传;剥 hop-by-hop 与伪造 X-Caller-App-Key、签名头四件原样;超限裸413/连接失败裸502/超时裸504)

This commit is contained in:
chenxing
2026-09-21 09:22:07 +08:00
parent 58a1bb5358
commit cdc4396ec2
2 changed files with 485 additions and 0 deletions
@@ -0,0 +1,228 @@
package com.yunhai.platform.gateway.forward;
import com.sun.net.httpserver.HttpServer;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* StreamingForwarder 五用例(T1.3 TDD,假后端 = JDK 自带 com.sun.net.httpserver.HttpServer,
* 零新增测试依赖):GET 转发 forwardUrl 逐字符 + 响应透传;POST body 流式透传;
* 客户端伪造 X-Caller-App-Key 不出现在转发请求;签名头四件原样到达;
* 超时裸 504 / 连接拒绝裸 502(另附 body 超限裸 413——实现要点契约)。
*/
class StreamingForwarderTest {
private static HttpServer server;
private static int port;
/** 假后端最近一次收到的请求快照 */
private static volatile Map<String, String> lastHeaders;
private static volatile String lastMethod;
private static volatile String lastUri;
private static volatile byte[] lastBody;
/** 假后端行为控制 */
private static volatile int respStatus = 201;
private static volatile String respHeader = "X-Upstream:v1"; // name:value
private static volatile byte[] respBody = "hello-gateway".getBytes(StandardCharsets.UTF_8);
private static volatile long respSleepMs = 0;
private static volatile int hitCount = 0;
private static volatile boolean echoBody = false;
@BeforeAll
static void startFakeBackend() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(Executors.newFixedThreadPool(4));
server.createContext("/", exchange -> {
hitCount++;
lastMethod = exchange.getRequestMethod();
lastUri = exchange.getRequestURI().toString();
lastHeaders = new ConcurrentHashMap<>();
for (Map.Entry<String, java.util.List<String>> e : exchange.getRequestHeaders().entrySet()) {
lastHeaders.put(e.getKey().toLowerCase(), String.join(",", e.getValue()));
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = exchange.getRequestBody().read(buf)) > 0) {
bos.write(buf, 0, n);
}
lastBody = bos.toByteArray();
if (respSleepMs > 0) {
try {
Thread.sleep(respSleepMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
byte[] body = echoBody ? lastBody : respBody;
if (respHeader != null) {
String[] kv = respHeader.split(":", 2);
exchange.getResponseHeaders().add(kv[0], kv[1]);
}
exchange.sendResponseHeaders(respStatus, body.length == 0 ? -1 : body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
exchange.close();
});
server.start();
port = server.getAddress().getPort();
}
@AfterAll
static void stopFakeBackend() {
server.stop(0);
}
@BeforeEach
@AfterEach
void resetFakeBackend() {
respStatus = 201;
respHeader = "X-Upstream:v1";
respBody = "hello-gateway".getBytes(StandardCharsets.UTF_8);
respSleepMs = 0;
hitCount = 0;
echoBody = false;
lastHeaders = null;
lastMethod = null;
lastUri = null;
lastBody = null;
}
/** 同线程执行器:转发逻辑同步完成,测试无需起线程等待 */
private StreamingForwarder forwarder() {
CloseableHttpClient client = HttpClients.custom().disableRedirectHandling().build();
return new StreamingForwarder(client, Runnable::run);
}
private MockHttpServletRequest request(String method, String uri) {
MockHttpServletRequest req = new MockHttpServletRequest(method, uri);
req.setAsyncSupported(true); // 异步 Servlet 契约(T1.6 链上 filter 需 async-supported)
return req;
}
@Test
void GET转发_forwardUrl逐字符且响应原样透传() throws Exception {
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
req.setQueryString("a=1&b=2");
MockHttpServletResponse res = new MockHttpServletResponse();
String forwardUrl = "http://127.0.0.1:" + port + "/userbase/oapi/x?a=1&b=2";
forwarder().forward(req, res, forwardUrl, 5000, 1024 * 1024);
assertThat(lastUri).as("换主机不换路径:URI+query 逐字符送达").isEqualTo("/userbase/oapi/x?a=1&b=2");
assertThat(lastMethod).isEqualTo("GET");
assertThat(res.getStatus()).as("响应 status 透传").isEqualTo(201);
assertThat(res.getHeader("X-Upstream")).as("响应 header 透传").isEqualTo("v1");
assertThat(res.getContentAsString()).as("响应 body 原样透传").isEqualTo("hello-gateway");
}
@Test
void POST_body流式透传() throws Exception {
echoBody = true;
respStatus = 200;
byte[] payload = new byte[100 * 1024];
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) (i % 251);
}
MockHttpServletRequest req = request("POST", "/usermsg/oapi/msg/sendUserMsg");
req.setContent(payload);
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/usermsg/oapi/msg/sendUserMsg",
5000, 4 * 1024 * 1024);
assertThat(lastBody).as("请求 body 完整到达假后端").isEqualTo(payload);
assertThat(res.getContentAsByteArray()).as("响应 body 回显一致(不改写)").isEqualTo(payload);
}
@Test
void 客户端伪造XCallerAppKey不出现在转发请求() throws Exception {
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
req.addHeader("X-Caller-App-Key", "hack");
req.addHeader("Connection", "close"); // hop-by-hop 也不得透传
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(res.getStatus()).isEqualTo(201);
assertThat(lastHeaders).as("身份头唯一合法来源是网关(T1.4)").doesNotContainKey("x-caller-app-key");
}
@Test
void 签名头四件原样到达假后端() throws Exception {
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
req.addHeader("X-App-Key", "100617");
req.addHeader("X-Timestamp", "1758441600000");
req.addHeader("X-Signature", "Regw08O602Tp61nhH6oG/5VIvyfmU9ZbWs42P7OHzj4=");
req.addHeader("X-Nonce", "c7ce1f3e-9a1d-4b3e-8f2a-0b1c2d3e4f5a");
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(lastHeaders.get("x-app-key")).isEqualTo("100617");
assertThat(lastHeaders.get("x-timestamp")).isEqualTo("1758441600000");
assertThat(lastHeaders.get("x-signature")).isEqualTo("Regw08O602Tp61nhH6oG/5VIvyfmU9ZbWs42P7OHzj4=");
assertThat(lastHeaders.get("x-nonce")).as("R3:UserBase 自验签依赖").isEqualTo("c7ce1f3e-9a1d-4b3e-8f2a-0b1c2d3e4f5a");
}
@Test
void 假后端超时_裸504() throws Exception {
respSleepMs = 600;
MockHttpServletRequest req = request("GET", "/userbase/oapi/slow");
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/slow", 100, 1024 * 1024);
assertThat(res.getStatus()).isEqualTo(504);
assertThat(res.getContentAsByteArray()).as("裸码无信封").isEmpty();
}
@Test
void 连接拒绝_裸502() throws Exception {
int deadPort;
try (ServerSocket ss = new ServerSocket(0)) {
deadPort = ss.getLocalPort();
}
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + deadPort + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(res.getStatus()).isEqualTo(502);
assertThat(res.getContentAsByteArray()).isEmpty();
}
@Test
void body超限_裸413且不连后端() throws Exception {
MockHttpServletRequest req = request("POST", "/userbase/oapi/x");
req.setContent("0123456789".getBytes(StandardCharsets.UTF_8)); // 10B > 4B
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 4);
assertThat(res.getStatus()).isEqualTo(413);
assertThat(res.getContentAsByteArray()).as("裸码").isEmpty();
assertThat(hitCount).as("上限拦截在连接之前").isZero();
}
}