use std::collections::BTreeMap; use axum::{ Json, Router, extract::{Path, Query}, http::{HeaderMap, StatusCode}, response::Redirect, routing::{get, post}, }; use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest}; use crank_core::{HttpMethod, RestTarget}; use serde_json::{Value, json}; use tokio::net::TcpListener; #[tokio::test] async fn executes_rest_request_and_normalizes_json_response() { let base_url = spawn_test_server().await; let adapter = test_adapter(); let target = RestTarget { base_url, method: HttpMethod::Post, path_template: "/users/{user_id}".to_owned(), static_headers: BTreeMap::from([("x-static".to_owned(), "static".to_owned())]), }; let request = RestRequest { path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]), query_params: BTreeMap::from([("expand".to_owned(), "true".to_owned())]), headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]), body: Some(json!({ "name": "Ada" })), timeout_ms: 1_000, }; let response = adapter.execute(&target, &request).await.unwrap(); assert_eq!(response.status_code, 200); assert_eq!( response.body, json!({ "id": "42", "query": "true", "trace": "trace-123", "static": "static", "payload": { "name": "Ada" } }) ); } #[tokio::test] async fn returns_unexpected_status_with_normalized_body() { let base_url = spawn_test_server().await; let adapter = test_adapter(); let target = RestTarget { base_url, method: HttpMethod::Get, path_template: "/fail".to_owned(), static_headers: BTreeMap::new(), }; let request = RestRequest { path_params: BTreeMap::new(), query_params: BTreeMap::new(), headers: BTreeMap::new(), body: None, timeout_ms: 1_000, }; let error = adapter.execute(&target, &request).await.unwrap_err(); assert!(matches!( error, RestAdapterError::UnexpectedStatus { status: 502, body: Value::Object(_) } )); } #[test] fn rejects_private_targets_by_default() { let policy = OutboundHttpPolicy::default(); let error = policy .validate_base_url("http://127.0.0.1:8080") .unwrap_err(); assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. })); } #[test] fn accepts_explicit_private_ipv4_and_ipv6_targets() { let policy = OutboundHttpPolicy::allowing_hosts(["192.168.1.10", "::1"]); assert!(policy.validate_base_url("http://192.168.1.10:8080").is_ok()); assert!(policy.validate_base_url("http://[::1]:8080").is_ok()); } #[tokio::test] async fn does_not_follow_redirects() { let base_url = spawn_test_server().await; let adapter = test_adapter(); let target = RestTarget { base_url, method: HttpMethod::Get, path_template: "/redirect".to_owned(), static_headers: BTreeMap::new(), }; let error = adapter .execute(&target, &empty_request()) .await .unwrap_err(); assert!(matches!( error, RestAdapterError::UnexpectedStatus { status: 303, .. } )); } #[tokio::test] async fn rejects_responses_over_the_configured_limit() { let base_url = spawn_test_server().await; let adapter = RestAdapter::with_policy( OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_response_bytes(8), ); let target = RestTarget { base_url, method: HttpMethod::Get, path_template: "/large".to_owned(), static_headers: BTreeMap::new(), }; let error = adapter .execute(&target, &empty_request()) .await .unwrap_err(); assert!(matches!( error, RestAdapterError::ResponseTooLarge { limit_bytes: 8 } )); } fn empty_request() -> RestRequest { RestRequest { path_params: BTreeMap::new(), query_params: BTreeMap::new(), headers: BTreeMap::new(), body: None, timeout_ms: 1_000, } } fn test_adapter() -> RestAdapter { RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"])) } async fn spawn_test_server() -> String { let app = Router::new() .route("/users/{user_id}", post(create_user)) .route("/fail", get(fail)) .route("/redirect", get(|| async { Redirect::to("/large") })) .route( "/large", get(|| async { "response larger than eight bytes" }), ); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); format!("http://{}", address) } async fn create_user( Path(user_id): Path, Query(query): Query>, headers: HeaderMap, Json(payload): Json, ) -> Json { let trace = headers .get("x-trace-id") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); let static_header = headers .get("x-static") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); Json(json!({ "id": user_id, "query": query.get("expand").cloned().unwrap_or_default(), "trace": trace, "static": static_header, "payload": payload })) } async fn fail() -> (axum::http::StatusCode, Json) { ( StatusCode::BAD_GATEWAY, Json(json!({ "error": "upstream failed" })), ) }