mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-21 16:00:49 +00:00
243 lines
9.2 KiB
Rust
243 lines
9.2 KiB
Rust
// Integration tests for ANTHROPIC_FORWARD_CLIENT_AUTH (BACKEND=anthropic
|
|
// passthrough only). Drives the real /v1/messages route
|
|
// (`anthropic_passthrough`) against a mock upstream that records the
|
|
// credential header it actually received, proving:
|
|
// - toggle off -> the operator's own configured credential always reaches
|
|
// upstream, regardless of what the client sent (regression baseline).
|
|
// - toggle on + PROXY_OPEN_RELAY (auth_path = OpenRelay) -> the client's
|
|
// own header is forwarded verbatim instead, byte-for-byte (no
|
|
// x-api-key<->Bearer re-shaping).
|
|
|
|
use anyllm_proxy::config::{self, BackendAuth, BackendKind, Config, ModelMapping, OpenAIApiFormat};
|
|
use anyllm_proxy::server::routes;
|
|
use axum::{extract::Request, response::IntoResponse, routing::post, Router};
|
|
use reqwest::Client;
|
|
use serde_json::json;
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::net::TcpListener;
|
|
|
|
// Serializes tests in this file that mutate process env vars
|
|
// (ANTHROPIC_FORWARD_CLIENT_AUTH, PROXY_OPEN_RELAY): each test needs a
|
|
// different value, unlike thinking_repair.rs's tests which all want the same
|
|
// ones. This file compiles to its own test binary, so it can't race with
|
|
// other integration test files -- only with itself. `tokio::sync::Mutex`,
|
|
// not `std::sync::Mutex`, because the guard is held across `.await` points
|
|
// (clippy::await_holding_lock).
|
|
static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
|
|
fn anthropic_config_with_base(base_url: &str) -> Config {
|
|
Config {
|
|
backend: BackendKind::Anthropic,
|
|
openai_api_key: "operator-secret-key".to_string(),
|
|
openai_base_url: base_url.to_string(),
|
|
listen_port: 0,
|
|
model_mapping: ModelMapping {
|
|
big_model: String::new(),
|
|
small_model: String::new(),
|
|
},
|
|
tls: config::TlsConfig::default(),
|
|
backend_auth: BackendAuth::AnthropicApiKey("operator-secret-key".to_string()),
|
|
log_bodies: false,
|
|
redact_secrets: false,
|
|
anthropic_thinking_repair: false,
|
|
pxpipe_compress: false,
|
|
expose_degradation_warnings: false,
|
|
openai_api_format: OpenAIApiFormat::Chat,
|
|
provider_id: None,
|
|
}
|
|
}
|
|
|
|
async fn spawn_proxy(config: Config) -> String {
|
|
let app = routes::app(config);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
/// Mock upstream Anthropic API: records the exact `x-api-key`/`authorization`
|
|
/// header it received (name + value) and acks with a minimal valid response.
|
|
async fn spawn_mock_anthropic_backend(received: Arc<Mutex<Vec<(String, String)>>>) -> String {
|
|
let app = Router::new().route(
|
|
"/v1/messages",
|
|
post(move |req: Request| {
|
|
let received = received.clone();
|
|
async move {
|
|
let headers = req.headers().clone();
|
|
if let Some(v) = headers.get("x-api-key") {
|
|
received
|
|
.lock()
|
|
.unwrap()
|
|
.push(("x-api-key".to_string(), v.to_str().unwrap().to_string()));
|
|
}
|
|
if let Some(v) = headers.get("authorization") {
|
|
received
|
|
.lock()
|
|
.unwrap()
|
|
.push(("authorization".to_string(), v.to_str().unwrap().to_string()));
|
|
}
|
|
axum::Json(json!({
|
|
"id": "msg_ack",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "ack"}],
|
|
"model": "claude-opus-4-5",
|
|
"usage": {"input_tokens": 5, "output_tokens": 1}
|
|
}))
|
|
.into_response()
|
|
}
|
|
}),
|
|
);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn toggle_off_always_forwards_operator_credential() {
|
|
let _lock = ENV_LOCK.lock().await;
|
|
std::env::remove_var("ANTHROPIC_FORWARD_CLIENT_AUTH");
|
|
std::env::set_var("PROXY_OPEN_RELAY", "true");
|
|
|
|
let received = Arc::new(Mutex::new(Vec::new()));
|
|
let mock = spawn_mock_anthropic_backend(received.clone()).await;
|
|
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
|
|
|
|
let resp = Client::new()
|
|
.post(format!("{proxy}/v1/messages"))
|
|
.header("x-api-key", "client-sent-key-must-be-ignored")
|
|
.json(&json!({
|
|
"model": "claude-opus-4-5",
|
|
"max_tokens": 1024,
|
|
"messages": [{"role": "user", "content": "hi"}]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let calls = received.lock().unwrap();
|
|
assert_eq!(
|
|
calls.as_slice(),
|
|
&[("x-api-key".to_string(), "operator-secret-key".to_string())],
|
|
"toggle off: only the operator's own configured credential must reach upstream"
|
|
);
|
|
|
|
std::env::remove_var("PROXY_OPEN_RELAY");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn toggle_on_with_open_relay_forwards_client_x_api_key_verbatim() {
|
|
let _lock = ENV_LOCK.lock().await;
|
|
std::env::set_var("ANTHROPIC_FORWARD_CLIENT_AUTH", "true");
|
|
std::env::set_var("PROXY_OPEN_RELAY", "true");
|
|
|
|
let received = Arc::new(Mutex::new(Vec::new()));
|
|
let mock = spawn_mock_anthropic_backend(received.clone()).await;
|
|
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
|
|
|
|
let resp = Client::new()
|
|
.post(format!("{proxy}/v1/messages"))
|
|
.header("x-api-key", "clients-own-subscription-derived-key")
|
|
.json(&json!({
|
|
"model": "claude-opus-4-5",
|
|
"max_tokens": 1024,
|
|
"messages": [{"role": "user", "content": "hi"}]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let calls = received.lock().unwrap();
|
|
assert_eq!(
|
|
calls.as_slice(),
|
|
&[(
|
|
"x-api-key".to_string(),
|
|
"clients-own-subscription-derived-key".to_string()
|
|
)],
|
|
"toggle on + open relay: the client's own header must be forwarded, operator credential absent"
|
|
);
|
|
|
|
std::env::remove_var("ANTHROPIC_FORWARD_CLIENT_AUTH");
|
|
std::env::remove_var("PROXY_OPEN_RELAY");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn toggle_on_with_open_relay_forwards_bearer_token_unmodified() {
|
|
let _lock = ENV_LOCK.lock().await;
|
|
std::env::set_var("ANTHROPIC_FORWARD_CLIENT_AUTH", "true");
|
|
std::env::set_var("PROXY_OPEN_RELAY", "true");
|
|
|
|
let received = Arc::new(Mutex::new(Vec::new()));
|
|
let mock = spawn_mock_anthropic_backend(received.clone()).await;
|
|
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
|
|
|
|
let resp = Client::new()
|
|
.post(format!("{proxy}/v1/messages"))
|
|
.header("authorization", "Bearer sk-ant-oat-subscription-token")
|
|
.json(&json!({
|
|
"model": "claude-opus-4-5",
|
|
"max_tokens": 1024,
|
|
"messages": [{"role": "user", "content": "hi"}]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let calls = received.lock().unwrap();
|
|
assert_eq!(
|
|
calls.as_slice(),
|
|
&[(
|
|
"authorization".to_string(),
|
|
"Bearer sk-ant-oat-subscription-token".to_string()
|
|
)],
|
|
"must forward the Bearer token as-is -- never converted to x-api-key"
|
|
);
|
|
|
|
std::env::remove_var("ANTHROPIC_FORWARD_CLIENT_AUTH");
|
|
std::env::remove_var("PROXY_OPEN_RELAY");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn toggle_on_with_open_relay_forwards_x_goog_api_key_as_x_api_key() {
|
|
// Regression test: validate_auth (server/middleware/auth.rs) treats
|
|
// x-goog-api-key as equally valid to x-api-key for authentication, but
|
|
// Anthropic's API only understands x-api-key -- the client's
|
|
// x-goog-api-key value must reach upstream renamed to x-api-key, not
|
|
// silently dropped in favor of the operator's own credential and not
|
|
// forwarded under a header name Anthropic would ignore.
|
|
let _lock = ENV_LOCK.lock().await;
|
|
std::env::set_var("ANTHROPIC_FORWARD_CLIENT_AUTH", "true");
|
|
std::env::set_var("PROXY_OPEN_RELAY", "true");
|
|
|
|
let received = Arc::new(Mutex::new(Vec::new()));
|
|
let mock = spawn_mock_anthropic_backend(received.clone()).await;
|
|
let proxy = spawn_proxy(anthropic_config_with_base(&mock)).await;
|
|
|
|
let resp = Client::new()
|
|
.post(format!("{proxy}/v1/messages"))
|
|
.header("x-goog-api-key", "gemini-cli-compat-key")
|
|
.json(&json!({
|
|
"model": "claude-opus-4-5",
|
|
"max_tokens": 1024,
|
|
"messages": [{"role": "user", "content": "hi"}]
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let calls = received.lock().unwrap();
|
|
assert_eq!(
|
|
calls.as_slice(),
|
|
&[("x-api-key".to_string(), "gemini-cli-compat-key".to_string())],
|
|
"x-goog-api-key must be forwarded upstream renamed to x-api-key, not dropped"
|
|
);
|
|
|
|
std::env::remove_var("ANTHROPIC_FORWARD_CLIENT_AUTH");
|
|
std::env::remove_var("PROXY_OPEN_RELAY");
|
|
}
|