#[cfg(test)]
mod client_transport_behavior {
use std::time::Duration;
use base64::Engine;
use rskit_errors::AppError;
use rskit_errors::ErrorCode;
use rskit_httpclient::{
Auth, DestinationPolicy, HttpClient, HttpClientConfig, Request, RequestBody,
};
use rskit_resilience::{ConstantBackoff, Policy, RetryPolicy};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use wiremock::matchers::{body_string, header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn simple_get_request_returns_successful_response() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/users"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"[{"id": 1, "name": "Alice"}]"#),
)
.mount(&mock_server)
.await;
let config = HttpClientConfig::new().with_base_url(mock_server.uri());
let client = HttpClient::new(config).unwrap();
let resp = client.get("/api/users").await.unwrap();
assert!(resp.is_success());
let body = resp.text().unwrap();
assert!(body.contains("Alice"));
}
#[tokio::test]
async fn post_with_json_body_returns_created_response() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/users"))
.respond_with(
ResponseTemplate::new(201).set_body_string(r#"{"id": 42, "name": "Bob"}"#),
)
.mount(&mock_server)
.await;
let config = HttpClientConfig::new().with_base_url(mock_server.uri());
let client = HttpClient::new(config).unwrap();
let body = serde_json::json!({"name": "Bob"});
let resp = client.post("/api/users", &body).await.unwrap();
assert_eq!(resp.status().as_u16(), 201);
let result: serde_json::Value = resp.json().unwrap();
assert_eq!(result["id"], 42);
}
#[tokio::test]
async fn bearer_token_auth_sends_authorized_request() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/protected"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"data": "secret"}"#))
.mount(&mock_server)
.await;
let config = HttpClientConfig::new().with_base_url(mock_server.uri());
let client = HttpClient::new(config).unwrap();
let resp = client
.send(Request::get("/api/protected").bearer_token("secret-token"))
.await
.unwrap();
assert!(resp.is_success());
}
#[tokio::test]
async fn error_response_can_be_checked_for_status_failure() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/missing"))
.respond_with(ResponseTemplate::new(404).set_body_string(r#"{"error": "not found"}"#))
.mount(&mock_server)
.await;
let config = HttpClientConfig::new().with_base_url(mock_server.uri());
let client = HttpClient::new(config).unwrap();
let resp = client.get("/api/missing").await.unwrap();
assert!(!resp.is_success());
assert_eq!(resp.status().as_u16(), 404);
let result = resp.error_for_status();
assert!(result.is_err());
}
#[tokio::test]
async fn resilience_policy_retries_transport_execution() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut first, _) = listener.accept().await.unwrap();
let mut buffer = [0_u8; 1024];
let _ = first.read(&mut buffer).await;
drop(first);
let (mut second, _) = listener.accept().await.unwrap();
let _ = second.read(&mut buffer).await;
second
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok")
.await
.unwrap();
second.shutdown().await.unwrap();
});
let policy = Policy::new().with_retry(
RetryPolicy::new()
.with_max_attempts(2)
.with_constant_backoff(ConstantBackoff::new(Duration::from_millis(1)))
.with_jitter(false),
);
let config = HttpClientConfig::new()
.with_base_url(format!("http://{address}"))
.with_resilience_policy(policy);
let client = HttpClient::new(config).unwrap();
let response = client.get("/retry").await.unwrap();
assert_eq!(response.status().as_u16(), 200);
assert_eq!(response.text().unwrap(), "ok");
server.await.unwrap();
}
#[tokio::test]
async fn response_body_limit_rejects_oversized_body() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/large"))
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(32)))
.mount(&mock_server)
.await;
let config = HttpClientConfig::new()
.with_base_url(mock_server.uri())
.with_max_response_body_bytes(16);
let client = HttpClient::new(config).unwrap();
let result = client.get("/large").await;
let error = result.unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
assert!(!error.is_retryable());
assert!(error.message().contains("max_response_body_bytes"));
}
#[tokio::test]
async fn redirect_destination_policy_rejects_metadata_target() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/redirect"))
.respond_with(
ResponseTemplate::new(302)
.append_header("location", "http://169.254.169.254/latest/meta-data"),
)
.mount(&mock_server)
.await;
let config = HttpClientConfig::new().with_base_url(mock_server.uri());
let client = HttpClient::new(config).unwrap();
let result = client.get("/redirect").await;
let error = result.unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
assert!(!error.is_retryable());
assert!(
error
.message()
.contains("metadata service destinations are blocked")
);
}
#[tokio::test]
async fn redirect_limit_rejects_more_than_configured_hops() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/redirect"))
.respond_with(ResponseTemplate::new(302).append_header("location", "/middle"))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/middle"))
.respond_with(ResponseTemplate::new(302).append_header("location", "/final"))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/final"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&mock_server)
.await;
let config = HttpClientConfig::new()
.with_base_url(mock_server.uri())
.with_max_redirects(1);
let client = HttpClient::new(config).unwrap();
let result = client.get("/redirect").await;
let error = result.unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
assert!(!error.is_retryable());
assert!(error.message().contains("max_redirects"));
}
#[tokio::test]
async fn redirect_limit_allows_exactly_configured_hops() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/redirect"))
.respond_with(ResponseTemplate::new(302).append_header("location", "/final"))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/final"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&mock_server)
.await;
let config = HttpClientConfig::new()
.with_base_url(mock_server.uri())
.with_max_redirects(1);
let client = HttpClient::new(config).unwrap();
let response = client.get("/redirect").await.unwrap();
assert_eq!(response.text().unwrap(), "ok");
}
#[tokio::test]
async fn allow_list_rejects_disallowed_host() {
let mock_server = MockServer::start().await;
let config = HttpClientConfig::new()
.with_base_url(mock_server.uri())
.with_destination_policy(
DestinationPolicy::new().with_allowed_hosts(["api.example.com"]),
);
let client = HttpClient::new(config).unwrap();
let result = client.get("/api/users").await;
let error = result.unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidInput);
assert!(!error.is_retryable());
assert!(error.message().contains("not allowed"));
}
#[tokio::test]
async fn convenience_methods_parse_json_and_checked_statuses() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/json"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"value":1}"#))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/json"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"created":true}"#))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/json"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"updated":true}"#))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/json"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"patched":true}"#))
.mount(&mock_server)
.await;
Mock::given(method("DELETE"))
.and(path("/gone"))
.respond_with(ResponseTemplate::new(204).set_body_string(""))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/conflict"))
.respond_with(ResponseTemplate::new(409).set_body_string("duplicate"))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/invalid-json"))
.respond_with(ResponseTemplate::new(200).set_body_string("not-json"))
.mount(&mock_server)
.await;
let client =
HttpClient::new(HttpClientConfig::new().with_base_url(mock_server.uri())).unwrap();
let get: serde_json::Value = client.get_json("/json").await.unwrap();
assert_eq!(get["value"], 1);
let post: serde_json::Value = client
.post_json("/json", &serde_json::json!({"name":"new"}))
.await
.unwrap();
assert_eq!(post["created"], true);
let put: serde_json::Value = client
.put_json("/json", &serde_json::json!({"name":"updated"}))
.await
.unwrap();
assert_eq!(put["updated"], true);
let patch: serde_json::Value = client
.patch_json("/json", &serde_json::json!({"name":"patched"}))
.await
.unwrap();
assert_eq!(patch["patched"], true);
assert_eq!(client.delete("/gone").await.unwrap().status_u16(), 204);
let conflict = client
.send_checked(Request::get("/conflict"))
.await
.unwrap_err();
assert_eq!(conflict.code(), ErrorCode::Conflict);
assert_eq!(
conflict
.details()
.get("body")
.and_then(serde_json::Value::as_str),
Some("duplicate")
);
let invalid_json = client
.get("/invalid-json")
.await
.unwrap()
.checked_json::<serde_json::Value>()
.unwrap_err();
assert_eq!(invalid_json.code(), ErrorCode::InvalidInput);
}
#[tokio::test]
async fn request_builder_applies_headers_queries_auth_and_text_or_byte_bodies() {
let mock_server = MockServer::start().await;
let basic_user = format!("user-{:08x}", rand::random::<u32>());
let basic_password = format!("pw-{:08x}", rand::random::<u32>());
let expected_basic = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD
.encode(format!("{basic_user}:{basic_password}"))
);
Mock::given(method("POST"))
.and(path("/text"))
.and(query_param("mode", "text"))
.and(header("x-custom", "override"))
.and(header("authorization", expected_basic.as_str()))
.and(body_string("hello"))
.respond_with(ResponseTemplate::new(200).set_body_string("text-ok"))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/bytes"))
.and(header("x-default", "default"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0xff]))
.mount(&mock_server)
.await;
let client = HttpClient::new(
HttpClientConfig::new()
.with_base_url(mock_server.uri())
.with_header("x-default", "default")
.with_auth(Auth::bearer("default-token")),
)
.unwrap();
let text_response = client
.send(
Request::new(http::Method::POST, "/old")
.path("/text")
.header("x-custom", "override")
.query_param("mode", "text")
.basic_auth(&basic_user, &basic_password)
.body(RequestBody::text("hello")),
)
.await
.unwrap();
assert_eq!(text_response.checked_text().unwrap(), "text-ok");
let bytes_response = client
.send(Request::patch("/bytes").body(RequestBody::bytes(vec![1_u8, 2, 3])))
.await
.unwrap();
assert_eq!(bytes_response.status_u16(), 200);
assert_eq!(bytes_response.text_or_diagnostic(), "<non-utf8 body>");
}
#[tokio::test]
async fn checked_helpers_support_custom_error_mapping_and_body_consumption() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/teapot"))
.respond_with(
ResponseTemplate::new(418)
.append_header("x-request-id", "req-1")
.set_body_string("short and stout"),
)
.mount(&mock_server)
.await;
Mock::given(method("HEAD"))
.and(path("/head"))
.respond_with(ResponseTemplate::new(200).append_header("x-answer", "42"))
.mount(&mock_server)
.await;
let client =
HttpClient::new(HttpClientConfig::new().with_base_url(mock_server.uri())).unwrap();
let mapped = client
.get("/teapot")
.await
.unwrap()
.checked_text_with(|response| {
AppError::new(ErrorCode::ExternalService, "mapped teapot")
.with_detail("status", response.status.as_u16().to_string())
.with_detail(
"request_id",
response.header("x-request-id").cloned().unwrap(),
)
.with_detail("body", response.body)
})
.unwrap_err();
assert_eq!(mapped.code(), ErrorCode::ExternalService);
assert_eq!(
mapped
.details()
.get("request_id")
.and_then(serde_json::Value::as_str),
Some("req-1")
);
let head = client.send(Request::head("/head")).await.unwrap();
assert!(head.is_success());
assert_eq!(head.header("X-Answer").map(String::as_str), Some("42"));
assert!(head.headers().contains_key("x-answer") || head.headers().contains_key("X-Answer"));
assert!(head.into_bytes().is_empty());
}
#[tokio::test]
async fn head_convenience_method_returns_headers_without_body() {
let mock_server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/status"))
.respond_with(ResponseTemplate::new(204).append_header("x-status", "ok"))
.mount(&mock_server)
.await;
let client =
HttpClient::new(HttpClientConfig::new().with_base_url(mock_server.uri())).unwrap();
let response = client.head("/status").await.unwrap();
assert_eq!(response.status_u16(), 204);
assert_eq!(response.header("x-status").map(String::as_str), Some("ok"));
assert!(response.into_bytes().is_empty());
}
#[tokio::test]
async fn unsupported_method_and_invalid_headers_are_rejected_before_transport() {
let mock_server = MockServer::start().await;
let client =
HttpClient::new(HttpClientConfig::new().with_base_url(mock_server.uri())).unwrap();
let unsupported = client
.send(Request::new(http::Method::OPTIONS, "/options"))
.await
.unwrap_err();
assert_eq!(unsupported.code(), ErrorCode::InvalidInput);
assert!(unsupported.message().contains("unsupported http method"));
let invalid_header_name = client
.send(Request::get("/").header("bad header", "value"))
.await
.unwrap_err();
assert_eq!(invalid_header_name.code(), ErrorCode::InvalidInput);
let invalid_header_value = client
.send(Request::get("/").header("x-bad", "bad\nvalue"))
.await
.unwrap_err();
assert_eq!(invalid_header_value.code(), ErrorCode::InvalidInput);
}
#[test]
fn bearer_auth_display_redacts_token_value() {
let auth = Auth::bearer("token123");
assert_eq!(auth.to_string(), "Bearer");
}
#[test]
fn basic_auth_display_redacts_credentials() {
let password = format!("pw-{:08x}", rand::random::<u32>());
let auth = Auth::basic("user", password);
assert_eq!(auth.to_string(), "Basic");
}
#[test]
fn api_key_auth_display_redacts_key_value() {
let auth = Auth::api_key("X-API-Key", "key123");
assert_eq!(auth.to_string(), "ApiKey(X-API-Key)");
}
}