feat: complete Epic 1 production foundation
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user