feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use axum::{
Json, Router,
@@ -9,7 +9,8 @@ use axum::{
};
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
use crank_core::{
HttpMethod, PreparedRequest, ProtocolAdapter, RestTarget, RuntimeRequestContext, Target,
DispatchEvidence, HttpMethod, PreparedRequest, ProtocolAdapter, ProtocolAdapterError,
RestTarget, RuntimeRequestContext, Target,
};
use opentelemetry::{
global,
@@ -38,6 +39,7 @@ async fn executes_rest_request_and_normalizes_json_response() {
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..RestRequest::default()
};
let response = adapter.execute(&target, &request).await.unwrap();
@@ -55,6 +57,77 @@ async fn executes_rest_request_and_normalizes_json_response() {
);
}
#[tokio::test]
async fn prepared_auth_profile_header_is_allowed_after_runtime_resolution() {
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::new(),
};
let request = RestRequest {
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
query_params: BTreeMap::new(),
headers: BTreeMap::from([(
"authorization".to_owned(),
"Bearer resolved-auth-profile-token".to_owned(),
)]),
trusted_header_names: BTreeSet::from(["authorization".to_owned()]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
};
let response = adapter.execute(&target, &request).await.unwrap();
assert_eq!(
response.body["authorization"],
"Bearer resolved-auth-profile-token"
);
}
#[tokio::test]
async fn untrusted_prepared_credential_header_is_rejected_before_dispatch() {
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::new(),
};
let request = RestRequest {
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
query_params: BTreeMap::new(),
headers: BTreeMap::from([(
"authorization".to_owned(),
"Bearer user-mapped-secret".to_owned(),
)]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..RestRequest::default()
};
let error = adapter.execute(&target, &request).await.unwrap_err();
assert!(matches!(error, RestAdapterError::InvalidHeaderName { .. }));
assert!(!format!("{error:?} {error}").contains("user-mapped-secret"));
}
#[test]
fn transport_error_debug_is_redacted() {
let error = RestAdapterError::Transport {
timeout: false,
connect: true,
};
let rendered = format!("{error:?} {error}");
assert!(!rendered.contains("http://"));
assert!(!rendered.contains("story19-transport-canary"));
assert!(rendered.contains("Transport"));
}
#[tokio::test]
async fn protocol_context_overrides_mapped_correlation_headers() {
let base_url = spawn_test_server().await;
@@ -134,6 +207,7 @@ async fn current_trace_context_overrides_mapped_traceparent() {
)]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..RestRequest::default()
};
let response = test_adapter()
@@ -171,6 +245,7 @@ async fn user_configured_propagation_headers_are_removed_without_trusted_context
headers: BTreeMap::new(),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
..RestRequest::default()
};
let response = test_adapter().execute(&target, &request).await.unwrap();
@@ -196,17 +271,54 @@ async fn returns_unexpected_status_with_normalized_body() {
headers: BTreeMap::new(),
body: None,
timeout_ms: 1_000,
..RestRequest::default()
};
let error = adapter.execute(&target, &request).await.unwrap_err();
assert!(matches!(
error,
&error,
RestAdapterError::UnexpectedStatus {
status: 502,
body: Value::Object(_)
}
));
assert!(matches!(
ProtocolAdapterError::from(error),
ProtocolAdapterError::UnexpectedStatus {
status: 502,
dispatch: DispatchEvidence::MayHaveDispatched,
}
));
}
#[tokio::test]
async fn connect_failure_is_known_not_dispatched() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let target = RestTarget {
base_url: format!("http://{address}"),
method: HttpMethod::Post,
path_template: "/write".to_owned(),
static_headers: BTreeMap::new(),
};
let request = RestRequest {
path_params: BTreeMap::new(),
query_params: BTreeMap::new(),
headers: BTreeMap::new(),
body: Some(json!({"value": 1})),
timeout_ms: 1_000,
..RestRequest::default()
};
let error = test_adapter().execute(&target, &request).await.unwrap_err();
assert!(matches!(
ProtocolAdapterError::from(error),
ProtocolAdapterError::Transport {
dispatch: DispatchEvidence::NotDispatched,
}
));
}
#[test]
@@ -244,10 +356,7 @@ async fn does_not_follow_redirects() {
.await
.unwrap_err();
assert!(matches!(
error,
RestAdapterError::UnexpectedStatus { status: 303, .. }
));
assert!(matches!(error, RestAdapterError::RedirectNotAllowed));
}
#[tokio::test]
@@ -281,6 +390,7 @@ fn empty_request() -> RestRequest {
headers: BTreeMap::new(),
body: None,
timeout_ms: 1_000,
..RestRequest::default()
}
}
@@ -334,6 +444,9 @@ async fn create_user(
.get("tracestate")
.and_then(|value| value.to_str().ok());
let baggage = headers.get("baggage").and_then(|value| value.to_str().ok());
let authorization = headers
.get("authorization")
.and_then(|value| value.to_str().ok());
let mut response = json!({
"id": user_id,
@@ -370,6 +483,12 @@ async fn create_user(
if let Some(baggage) = baggage {
response.insert("baggage".to_owned(), Value::String(baggage.to_owned()));
}
if let Some(authorization) = authorization {
response.insert(
"authorization".to_owned(),
Value::String(authorization.to_owned()),
);
}
Json(Value::Object(response.clone()))
}
@@ -0,0 +1,194 @@
use std::{
collections::BTreeMap,
net::SocketAddr,
process::Command,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};
use axum::{
Json, Router,
extract::State,
http::StatusCode,
routing::{any, post},
};
use crank_adapter_rest::{OutboundHttpPolicy, RestAdapter, RestAdapterError, RestRequest};
use crank_core::{HttpMethod, ProtocolAdapterError, RestTarget};
use serde_json::{Value, json};
use tokio::net::TcpListener;
#[tokio::test]
async fn forbidden_credential_and_framing_headers_fail_before_bytes_leave() {
let observed_requests = Arc::new(AtomicUsize::new(0));
let base_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
let adapter = RestAdapter::with_policy(OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]));
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/capture".to_owned(),
static_headers: BTreeMap::from([
(
"authorization".to_owned(),
"Bearer story19-static-secret".to_owned(),
),
("host".to_owned(), "metadata.internal".to_owned()),
]),
};
let error = adapter
.execute(&target, &json_request(json!({"value": "must-not-leave"})))
.await
.expect_err("forbidden headers must reject before dispatch");
assert!(matches!(error, RestAdapterError::InvalidHeaderName { .. }));
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
let rendered = format!("{error:?} {error}");
assert!(!rendered.contains("story19-static-secret"));
assert!(!rendered.contains("metadata.internal"));
}
#[tokio::test]
async fn request_body_limit_fails_before_bytes_leave() {
let observed_requests = Arc::new(AtomicUsize::new(0));
let base_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
let adapter = RestAdapter::with_policy(
OutboundHttpPolicy::allowing_hosts(["127.0.0.1"]).with_max_request_bytes(16),
);
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/capture".to_owned(),
static_headers: BTreeMap::new(),
};
let error = adapter
.execute(
&target,
&json_request(json!({"payload": "story19-body-canary-that-exceeds-limit"})),
)
.await
.expect_err("oversized request body must fail before dispatch");
assert!(matches!(
error,
RestAdapterError::RequestTooLarge { limit_bytes: 16 }
));
let rendered = format!("{error:?} {error}");
assert_eq!(
ProtocolAdapterError::from(error),
ProtocolAdapterError::RequestTooLarge
);
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
assert!(!rendered.contains("story19-body-canary"));
}
#[tokio::test]
async fn proxy_environment_is_ignored_by_default() {
const CHILD_ENV: &str = "CRANK_OUTBOUND_PROXY_CHILD";
const TARGET_ENV: &str = "CRANK_OUTBOUND_PROXY_TARGET";
if std::env::var_os(CHILD_ENV).is_some() {
let target = std::env::var(TARGET_ENV).expect("target url passed by parent");
let adapter = RestAdapter::default();
let request = json_request(json!({"payload": "proxy-env-canary"}));
let rest_target = RestTarget {
base_url: target,
method: HttpMethod::Post,
path_template: "/capture".to_owned(),
static_headers: BTreeMap::new(),
};
let _ = adapter.execute(&rest_target, &request).await.expect_err(
"unresolvable target should fail locally instead of being sent through proxy env",
);
return;
}
let observed_requests = Arc::new(AtomicUsize::new(0));
let proxy_url = spawn_counting_server(Arc::clone(&observed_requests)).await;
let current_exe = std::env::current_exe().expect("current test binary");
let status = Command::new(current_exe)
.arg("--exact")
.arg("integration::outbound_security::proxy_environment_is_ignored_by_default")
.arg("--nocapture")
.env(CHILD_ENV, "1")
.env(TARGET_ENV, "http://public.example.test/capture")
.env("HTTP_PROXY", &proxy_url)
.env("HTTPS_PROXY", &proxy_url)
.env("ALL_PROXY", &proxy_url)
.status()
.expect("spawn child proxy regression");
assert!(status.success());
assert_eq!(observed_requests.load(Ordering::SeqCst), 0);
}
#[test]
fn rejected_target_diagnostic_is_redacted() {
let error = OutboundHttpPolicy::default()
.validate_base_url("http://user:story19-url-secret@127.0.0.1:8080/private")
.expect_err("userinfo and private target must be rejected");
let rendered = format!("{error:?} {error}");
assert!(!rendered.contains("story19-url-secret"));
assert!(!rendered.contains("127.0.0.1"));
assert!(!rendered.contains("/private"));
}
#[test]
fn deny_rule_wins_over_explicit_allow() {
let policy = OutboundHttpPolicy::try_new(
vec!["api.example.test".to_owned()],
vec!["api.example.test".to_owned()],
4 * 1024 * 1024,
)
.expect("valid policy");
let error = policy
.validate_base_url("https://api.example.test/users")
.expect_err("deny must override allow");
assert!(matches!(error, RestAdapterError::TargetNotAllowed { .. }));
}
fn json_request(body: Value) -> RestRequest {
RestRequest {
path_params: BTreeMap::new(),
query_params: BTreeMap::new(),
headers: BTreeMap::new(),
body: Some(body),
timeout_ms: 1_000,
..RestRequest::default()
}
}
async fn spawn_counting_server(observed_requests: Arc<AtomicUsize>) -> String {
let app = Router::new()
.route("/capture", post(capture))
.fallback(any(capture_any))
.with_state(observed_requests);
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.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
});
format!("http://{address}")
}
async fn capture(State(observed_requests): State<Arc<AtomicUsize>>) -> Json<Value> {
observed_requests.fetch_add(1, Ordering::SeqCst);
Json(json!({"ok": true}))
}
async fn capture_any(State(observed_requests): State<Arc<AtomicUsize>>) -> StatusCode {
observed_requests.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}