use amberfork_record::{Body, CaptureProxy};
use axum::Json;
use axum::Router;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::routing::post;
use serde_json::{Value, json};
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
const SECRET: &str = "sk-secret-do-not-record";
#[derive(Default)]
struct Upstream {
seen_auth: Mutex<Vec<String>>,
}
async fn chat_completions(
State(state): State<Arc<Upstream>>,
headers: HeaderMap,
Json(_body): Json<Value>,
) -> Json<Value> {
if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
state.seen_auth.lock().unwrap().push(auth.to_string());
}
Json(json!({
"id": "chatcmpl-fake",
"choices": [{ "message": { "role": "assistant", "content": "I'll look up the order first." } }],
}))
}
async fn fake_upstream() -> (String, Arc<Upstream>) {
let state = Arc::new(Upstream::default());
let app = Router::new()
.route("/v1/chat/completions", post(chat_completions))
.with_state(Arc::clone(&state));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.expect("bind fake upstream");
let addr = listener.local_addr().expect("upstream addr");
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://{addr}"), state)
}
async fn call_through(proxy: &CaptureProxy) -> reqwest::Response {
reqwest::Client::new()
.post(format!("{}/v1/chat/completions", proxy.base_url()))
.header("authorization", format!("Bearer {SECRET}"))
.header("content-type", "application/json")
.json(&json!({
"model": "claude-sonnet-5",
"messages": [{ "role": "user", "content": "Handle refund for order 8841" }],
}))
.send()
.await
.expect("call through proxy")
}
#[tokio::test]
async fn credential_reaches_upstream_but_never_the_cassette() {
let (upstream, seen) = fake_upstream().await;
let proxy = CaptureProxy::bind("run-a", &upstream)
.await
.expect("bind proxy");
call_through(&proxy).await;
assert_eq!(
seen.seen_auth.lock().unwrap().as_slice(),
&[format!("Bearer {SECRET}")],
"the credential must reach upstream — the proxy relays, it does not authenticate"
);
let serialized = serde_json::to_string(&proxy.cassette()).expect("serialize cassette");
assert!(
!serialized.contains(SECRET),
"credential leaked into the cassette: {serialized}"
);
}
#[tokio::test]
async fn cassette_captures_the_full_request_body() {
let (upstream, _seen) = fake_upstream().await;
let proxy = CaptureProxy::bind("run-a", &upstream)
.await
.expect("bind proxy");
call_through(&proxy).await;
let cassette = proxy.cassette();
assert_eq!(cassette.exchanges.len(), 1);
let exchange = &cassette.exchanges[0];
assert_eq!(exchange.idx, 0);
assert_eq!(exchange.request.method, "POST");
assert_eq!(exchange.request.path, "/v1/chat/completions");
let Body::Json(body) = &exchange.request.body else {
panic!(
"expected a JSON request body, got {:?}",
exchange.request.body
);
};
assert_eq!(body["model"], "claude-sonnet-5");
assert_eq!(
body["messages"][0]["content"],
"Handle refund for order 8841"
);
}
#[tokio::test]
async fn caller_receives_the_upstream_response_verbatim() {
let (upstream, _seen) = fake_upstream().await;
let proxy = CaptureProxy::bind("run-a", &upstream)
.await
.expect("bind proxy");
let response = call_through(&proxy).await;
assert_eq!(response.status(), 200);
let body: Value = response.json().await.expect("decode relayed response");
assert_eq!(body["id"], "chatcmpl-fake");
assert_eq!(
body["choices"][0]["message"]["content"],
"I'll look up the order first."
);
let cassette = proxy.cassette();
let Body::Json(recorded) = &cassette.exchanges[0].response.body else {
panic!("expected a JSON response body");
};
assert_eq!(
recorded, &body,
"what the caller got and what the cassette recorded must be the same bytes"
);
}
#[tokio::test]
async fn exchanges_are_recorded_in_capture_order() {
let (upstream, _seen) = fake_upstream().await;
let proxy = CaptureProxy::bind("run-a", &upstream)
.await
.expect("bind proxy");
call_through(&proxy).await;
call_through(&proxy).await;
call_through(&proxy).await;
let cassette = proxy.cassette();
let indices: Vec<usize> = cassette.exchanges.iter().map(|e| e.idx).collect();
assert_eq!(indices, vec![0, 1, 2]);
}
#[tokio::test]
async fn upstream_failure_surfaces_as_a_gateway_error_not_a_panic() {
let dead = {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.expect("bind to find a free port");
let addr = listener.local_addr().expect("addr");
drop(listener);
format!("http://{addr}")
};
let proxy = CaptureProxy::bind("run-a", &dead)
.await
.expect("bind proxy");
let response = call_through(&proxy).await;
assert_eq!(response.status(), 502);
assert!(
proxy.cassette().exchanges.is_empty(),
"an exchange that never completed upstream is not an exchange"
);
}