Files
crank/crates/crank-adapter-rest/tests/integration/client.rs
T
bsodfather 8318e4b560
CI / Rust Checks (push) Successful in 5m7s
CI / UI Checks (push) Successful in 4s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m9s
CI / Deploy (push) Successful in 1m41s
Усилить безопасность и надёжность выполнения операций
2026-07-11 14:08:07 +03:00

205 lines
5.6 KiB
Rust

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<String>,
Query(query): Query<BTreeMap<String, String>>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Json<Value> {
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<Value>) {
(
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "upstream failed" })),
)
}