237 lines
12 KiB
Rust
237 lines
12 KiB
Rust
// file: crates/ksp-offchain-transport-lib/unit_tests/http_client.rs
|
|
// version: 2
|
|
|
|
#[tokio::test]
|
|
async fn rest_client_accepts_bounded_json_and_never_exposes_request_debug() -> ksp_core_lib::Result<()> {
|
|
let server_result =
|
|
spawn_single_response("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\nConnection: close\r\n\r\n{\"price\":123}").await;
|
|
let server = match server_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mut request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
request.append_query_pair("symbol", "SOL/USD");
|
|
let secret = "sensitive-canary-value";
|
|
if let std::result::Result::Err(error) = request.insert_sensitive_header("x-api-key", secret) {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let debug = format!("{request:?}");
|
|
assert!(!debug.contains(secret));
|
|
assert!(!debug.contains(server.url.as_str()));
|
|
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let document = match client.get_json("test-provider", "sol_usd", request).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
assert_eq!(document.as_bytes(), br#"{"price":123}"#);
|
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
#[test]
|
|
fn https_request_builder_accepts_official_style_url_without_exposing_it() -> ksp_core_lib::Result<()> {
|
|
let mut request = match crate::HttpGetRequest::new_https("https://example.com/price") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
request.append_query_pair("symbol", "SOL/USD");
|
|
if let std::result::Result::Err(error) = request.insert_sensitive_header("x-api-key", "redaction-canary") {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let debug = format!("{request:?}");
|
|
assert!(!debug.contains("example.com"));
|
|
assert!(!debug.contains("redaction-canary"));
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rest_client_rejects_redirects_instead_of_following_them() -> ksp_core_lib::Result<()> {
|
|
let server_result =
|
|
spawn_single_response("HTTP/1.1 302 Found\r\nLocation: https://example.invalid/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await;
|
|
let server = match server_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let error = match client.get_json("test-provider", "redirect", request).await {
|
|
std::result::Result::Ok(_) => {
|
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test expected redirect rejection"));
|
|
},
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED);
|
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rest_client_bounds_chunked_body_before_json_deserialization() -> ksp_core_lib::Result<()> {
|
|
let server_result = spawn_single_response(concat!(
|
|
"HTTP/1.1 200 OK\r\n",
|
|
"Content-Type: application/json\r\n",
|
|
"Transfer-Encoding: chunked\r\n",
|
|
"Connection: close\r\n\r\n",
|
|
"8\r\n{\"aaaa\":\r\n",
|
|
"8\r\n\"bbbbbb\"\r\n",
|
|
"1\r\n}\r\n0\r\n\r\n",
|
|
))
|
|
.await;
|
|
let server = match server_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let settings = match crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 8) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let client = match crate::HttpRestClient::new(settings) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let error = match client.get_json("test-provider", "bounded", request).await {
|
|
std::result::Result::Ok(_) => {
|
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "test expected body bound"));
|
|
},
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE);
|
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rest_client_classifies_429_retry_after_and_never_copies_remote_body() -> ksp_core_lib::Result<()> {
|
|
let remote_canary = "REMOTE_SECRET_CANARY";
|
|
let response =
|
|
format!("HTTP/1.1 429 Too Many Requests\r\nRetry-After: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", remote_canary.len(), remote_canary);
|
|
let server_result = spawn_single_response(response.as_str()).await;
|
|
let server = match server_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let error = match client.get_json("test-provider", "rate_limited", request).await {
|
|
std::result::Result::Ok(_) => return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMITED, "test expected 429")),
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_RATE_LIMITED);
|
|
assert!(!format!("{error:?}").contains(remote_canary));
|
|
assert!(!error.to_string().contains(remote_canary));
|
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rest_client_rejects_invalid_json_after_success_status() -> ksp_core_lib::Result<()> {
|
|
let server_result = spawn_single_response("HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nnot-json").await;
|
|
let server = match server_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let error = match client.get_json("test-provider", "invalid_json", request).await {
|
|
std::result::Result::Ok(_) => {
|
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "test expected invalid JSON"));
|
|
},
|
|
std::result::Result::Err(error) => error,
|
|
};
|
|
assert_eq!(error.code(), crate::ERROR_CODE_HTTP_INVALID_JSON);
|
|
if let std::result::Result::Err(error) = finish_server(server.join).await {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
struct TestServer {
|
|
join: tokio::task::JoinHandle<std::io::Result<()>>,
|
|
url: std::string::String,
|
|
}
|
|
|
|
async fn spawn_single_response(response: &str) -> ksp_core_lib::Result<TestServer> {
|
|
let listener_result = tokio::net::TcpListener::bind("127.0.0.1:0").await;
|
|
let listener = match listener_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(io_error(error)),
|
|
};
|
|
let address = match listener.local_addr() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(io_error(error)),
|
|
};
|
|
let response = response.as_bytes().to_vec();
|
|
let join = tokio::spawn(async move {
|
|
let accept_result = listener.accept().await;
|
|
let (mut socket, _) = match accept_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mut request = [0_u8; 4096];
|
|
if let std::result::Result::Err(error) = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
if let std::result::Result::Err(error) = tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_slice()).await {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
if let std::result::Result::Err(error) = tokio::io::AsyncWriteExt::shutdown(&mut socket).await {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::io::Result::Ok(());
|
|
});
|
|
return std::result::Result::Ok(TestServer { join, url: format!("http://{address}/price") });
|
|
}
|
|
|
|
async fn finish_server(join: tokio::task::JoinHandle<std::io::Result<()>>) -> ksp_core_lib::Result<()> {
|
|
let joined = join.await;
|
|
return match joined {
|
|
std::result::Result::Ok(std::result::Result::Ok(())) => std::result::Result::Ok(()),
|
|
std::result::Result::Ok(std::result::Result::Err(error)) => std::result::Result::Err(io_error(error)),
|
|
std::result::Result::Err(error) => std::result::Result::Err(join_error(error)),
|
|
};
|
|
}
|
|
|
|
fn io_error(error: std::io::Error) -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test HTTP server failed").with_source(error);
|
|
}
|
|
|
|
fn join_error(error: tokio::task::JoinError) -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test HTTP server task failed").with_source(error);
|
|
}
|