T1.3: StreamingForwarder 流式转发(AsyncContext+HttpClient 零缓冲透传;剥 hop-by-hop 与伪造 X-Caller-App-Key、签名头四件原样;超限裸413/连接失败裸502/超时裸504)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user