This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 80fbc73e05 test(ai-proxy): verify proxy key isolation over real HTTP
(#7943)
80fbc73e05 is described below
commit 80fbc73e05eeecce7ebf84fda1409bcb8c70f751
Author: redyyang <[email protected]>
AuthorDate: Fri Oct 23 11:17:45 2026 +0800
test(ai-proxy): verify proxy key isolation over real HTTP (#7943)
* test(ai-proxy): verify proxy key isolation over real HTTP
* test(ai-proxy): reject invalid proxy keys before contacting upstream
* test(ci): align cryptor failures and wait for usable SDK routes
---
.../enhanced/AiProxyHttpCompatibilityTest.java | 91 ++++++++++++++++++++++
1 file changed, 91 insertions(+)
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyHttpCompatibilityTest.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyHttpCompatibilityTest.java
index 5fec678283..5bdd6d3a9d 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyHttpCompatibilityTest.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyHttpCompatibilityTest.java
@@ -21,10 +21,12 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.shenyu.common.constant.Constants;
import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.dto.ProxyApiKeyData;
import org.apache.shenyu.common.dto.RuleData;
import org.apache.shenyu.common.dto.SelectorData;
import org.apache.shenyu.common.dto.convert.rule.AiProxyHandle;
import org.apache.shenyu.plugin.ai.proxy.enhanced.handler.AiProxyPluginHandler;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.cache.AiProxyApiKeyCache;
import
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory;
import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat;
import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
@@ -43,6 +45,7 @@ import
org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import reactor.test.StepVerifier;
@@ -117,6 +120,94 @@ class AiProxyHttpCompatibilityTest {
}
}
+ @Test
+ void proxyKeysRemainIsolatedAcrossSequentialAndConcurrentRequests() {
+ // The engine replaced cached ChatClient instances; keep isolation
covered at the HTTP boundary.
+ DisposableServer server =
HttpServer.create().host("127.0.0.1").port(0).handle((request, response) ->
+ response.header("Content-Type", "application/json")
+ .header("X-Upstream-Account",
request.requestHeaders().get("Authorization"))
+ .sendString(Flux.just(CHAT_REPLY))).bindNow(TIMEOUT);
+ final AiProxyPluginHandler handler = new AiProxyPluginHandler();
+ AiProxyHandle handle = new AiProxyHandle();
+ handle.setProvider("OpenAI");
+ handle.setBaseUrl("http://127.0.0.1:" + server.port());
+ handle.setApiKey("selector-default");
+ handle.setStream(false);
+ handle.setProxyEnabled("true");
+ handle.setProtocol(OpenAiChat.NAME);
+ handle.setModel("model");
+
handler.getSelectorCachedHandle().cachedHandle(CacheKeyUtils.INST.getKey("http-selector",
Constants.DEFAULT_RULE), handle);
+ AiProxyPlugin plugin = plugin(handler);
+ for (String account : List.of("account-a", "account-b")) {
+
AiProxyApiKeyCache.getInstance().cache(ProxyApiKeyData.builder().selectorId("http-selector")
+ .proxyApiKey("proxy-" +
account).realApiKey(account).enabled(true).build());
+ }
+ try {
+ for (String account : List.of("account-a", "account-b",
"account-a")) {
+ StepVerifier.create(executeForAccount(plugin,
account)).verifyComplete();
+ }
+ StepVerifier.create(Flux.range(0, 32)
+ .flatMap(index -> executeForAccount(plugin, index % 2 == 0
? "account-a" : "account-b"), 8)
+ .then()).expectComplete().verify(Duration.ofSeconds(15));
+ assertEquals("selector-default", handle.getApiKey());
+ } finally {
+ server.disposeNow(TIMEOUT);
+ for (String account : List.of("account-a", "account-b")) {
+
AiProxyApiKeyCache.getInstance().remove(ProxyApiKeyData.builder().selectorId("http-selector")
+ .proxyApiKey("proxy-" + account).build());
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void unknownOrDisabledProxyKeysCannotUseTheSelectorCredential(final
boolean disabled) {
+ AtomicBoolean called = new AtomicBoolean();
+ DisposableServer server =
HttpServer.create().host("127.0.0.1").port(0).handle((request, response) -> {
+ called.set(true);
+ return response.sendString(Mono.just(CHAT_REPLY));
+ }).bindNow(TIMEOUT);
+ final AiProxyPluginHandler handler = new AiProxyPluginHandler();
+ AiProxyHandle handle = new AiProxyHandle();
+ handle.setProvider("OpenAI");
+ handle.setBaseUrl("http://127.0.0.1:" + server.port());
+ handle.setApiKey("selector-default");
+ handle.setProxyEnabled("true");
+ handle.setStream(false);
+ handle.setProtocol(OpenAiChat.NAME);
+ handle.setModel("model");
+
handler.getSelectorCachedHandle().cachedHandle(CacheKeyUtils.INST.getKey("http-selector",
Constants.DEFAULT_RULE), handle);
+ ProxyApiKeyData mapping =
ProxyApiKeyData.builder().selectorId("http-selector")
+
.proxyApiKey("rejected-proxy-key").realApiKey("disabled-real-key").enabled(false).build();
+ if (disabled) {
+ AiProxyApiKeyCache.getInstance().cache(mapping);
+ }
+ try {
+ MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.post("/chat")
+ .header(Constants.X_API_KEY, mapping.getProxyApiKey())
+
.body("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":false}"));
+ StepVerifier.create(plugin(handler).doExecute(exchange,
mock(ShenyuPluginChain.class), selector(), new RuleData()))
+ .expectComplete().verify(TIMEOUT);
+ assertEquals(401, exchange.getResponse().getStatusCode().value());
+ assertTrue(!called.get(), "rejected proxy keys must not open an
upstream connection");
+ assertEquals("selector-default", handle.getApiKey());
+ } finally {
+ AiProxyApiKeyCache.getInstance().remove(mapping);
+ server.disposeNow(TIMEOUT);
+ }
+ }
+
+ private Mono<Void> executeForAccount(final AiProxyPlugin plugin, final
String account) {
+ MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.post("/chat")
+ .header(Constants.X_API_KEY, "proxy-" + account)
+
.body("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":false}"));
+ return plugin.doExecute(exchange, mock(ShenyuPluginChain.class),
selector(), new RuleData())
+ .then(Mono.fromRunnable(() -> {
+ assertEquals(200,
exchange.getResponse().getStatusCode().value());
+ assertEquals("Bearer " + account,
exchange.getResponse().getHeaders().getFirst("X-Upstream-Account"));
+ }));
+ }
+
@Test
void rejectsResponsesStateBeforeOpeningAnUpstreamConnection() {
AtomicBoolean called = new AtomicBoolean();