#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::disallowed_methods,
clippy::disallowed_macros,
clippy::err_expect,
clippy::print_stdout,
clippy::useless_conversion
)]
mod support;
use std::sync::Arc;
use std::time::Duration;
use agent_first_http::sdk::fetch::RenderMode;
use agent_first_http::sdk::Client;
use agent_first_http::shared::artifacts::Artifact;
use axum::extract::State;
use axum::Router;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
async fn spawn_capturing_proxy() -> (String, Arc<Mutex<Vec<String>>>) {
let captures = Arc::new(Mutex::new(Vec::<String>::new()));
let state = captures.clone();
let app = Router::new()
.fallback(
|State(s): State<Arc<Mutex<Vec<String>>>>,
req: axum::http::Request<axum::body::Body>| async move {
let host = req
.headers()
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
s.lock().await.push(host);
axum::response::IntoResponse::into_response((
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/plain")],
"proxied",
))
},
)
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind proxy");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
tokio::time::sleep(Duration::from_millis(20)).await;
(format!("http://{addr}"), captures)
}
#[tokio::test]
async fn fetch_proxy_routes_through_capturing_upstream() {
let (proxy_url, captures) = spawn_capturing_proxy().await;
let fixture = support::fixture_server::spawn().await;
let tmpdir = tempfile::tempdir().expect("tempdir");
let client = Client::connect("ws://localhost:9999").expect("client");
let target = format!("{}/plain.html", fixture.base_url());
let result = client
.fetch(target.clone())
.render(RenderMode::None)
.proxy(proxy_url.clone())
.want([Artifact::Body])
.out_dir(tmpdir.path().to_path_buf())
.send()
.await
.expect("proxied fetch");
let body_file = result.body_file.as_ref().expect("body_file");
let bytes = tokio::fs::read(body_file).await.expect("read");
let body = String::from_utf8(bytes).expect("utf8");
assert!(
body.contains("proxied"),
"proxy should have answered the request; got {body:?}",
);
let seen = captures.lock().await;
let target_host = url::Url::parse(&target)
.expect("parse target")
.host_str()
.map(str::to_string)
.unwrap_or_default();
let target_port = url::Url::parse(&target)
.expect("parse target")
.port()
.map(|p| format!(":{p}"))
.unwrap_or_default();
let expected = format!("{target_host}{target_port}");
assert!(
seen.contains(&expected),
"proxy did not see Host: {expected}; captured {seen:?}",
);
}
#[tokio::test]
async fn fetch_without_proxy_goes_direct_and_ignores_ambient_env() {
let fixture = support::fixture_server::spawn().await;
let tmpdir = tempfile::tempdir().expect("tempdir");
let prior = std::env::var_os("HTTP_PROXY");
unsafe {
std::env::set_var("HTTP_PROXY", "http://127.0.0.1:1");
}
let client = Client::connect("ws://localhost:9999").expect("client");
let result = client
.fetch(format!("{}/plain.html", fixture.base_url()))
.render(RenderMode::None)
.want([Artifact::Body])
.out_dir(tmpdir.path().to_path_buf())
.send()
.await;
unsafe {
match prior {
Some(v) => std::env::set_var("HTTP_PROXY", v),
None => std::env::remove_var("HTTP_PROXY"),
}
}
let result = result.expect("direct fetch should ignore HTTP_PROXY env");
assert_eq!(result.status, 200);
let body_file = result.body_file.as_ref().expect("body_file");
let bytes = tokio::fs::read(body_file).await.expect("read");
assert!(String::from_utf8_lossy(&bytes).contains("Hello"));
}
#[tokio::test]
async fn fetch_invalid_proxy_url_is_invalid_argument() {
let tmpdir = tempfile::tempdir().expect("tempdir");
let client = Client::connect("ws://localhost:9999").expect("client");
let err = client
.fetch("http://127.0.0.1:1/will-not-be-reached")
.render(RenderMode::None)
.proxy("not a valid url")
.want([Artifact::Body])
.out_dir(tmpdir.path().to_path_buf())
.send()
.await
.err()
.expect("invalid proxy must error");
assert_eq!(
err.error_code,
agent_first_http::shared::error::ErrorCode::InvalidArgument,
);
}