// 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>, url: std::string::String, } async fn spawn_single_response(response: &str) -> ksp_core_lib::Result { 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>) -> 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); }