use super::test_support::{
fetch_runs, spawn_proxy, spawn_target, spawn_target_with_delay,
spawn_target_with_status_and_delay, spawn_uri_target, test_config, wait_for_run,
};
use axum::http::{header, HeaderValue, StatusCode};
use moonlight_core::{
config::{ResponseTiming, ReturnFallback, ReturnTarget},
Classification, ComparisonRun, MetricsSnapshot, RunInput, RunPage,
};
use std::time::{Duration, Instant};
use tempfile::tempdir;
#[tokio::test]
async fn proxy_returns_primary_and_records_run() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target(r#"{"source":"candidate"}"#).await;
let dir = tempdir().unwrap();
let config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let body = client
.get(format!("http://{proxy_addr}/anything?x=1"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, r#"{"source":"primary"}"#);
let runs = fetch_runs(&client, proxy_addr).await;
assert_eq!(runs.len(), 1);
assert!(matches!(
runs[0].input,
RunInput::Http { ref path, .. } if path == "/anything"
));
assert_eq!(runs[0].classification, Classification::SuspiciousDifference);
}
#[tokio::test]
async fn runs_api_filters_and_paginates() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target(r#"{"source":"candidate"}"#).await;
let dir = tempdir().unwrap();
let config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
client
.get(format!("http://{proxy_addr}/match"))
.send()
.await
.unwrap();
client
.get(format!("http://{proxy_addr}/regression"))
.send()
.await
.unwrap();
let page: RunPage = client
.get(format!(
"http://{proxy_addr}/api/runs?classification=suspicious_difference&adapter=http&q=regression&status=200&limit=1"
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items.len(), 1);
assert!(matches!(
page.items[0].input,
RunInput::Http { ref path, .. } if path == "/regression"
));
}
#[tokio::test]
async fn review_and_report_endpoints_work() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target(r#"{"source":"candidate"}"#).await;
let dir = tempdir().unwrap();
let config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap();
let run = wait_for_run(&client, proxy_addr).await;
let review: serde_json::Value = client
.put(format!("http://{proxy_addr}/api/runs/{}/review", run.id))
.json(&serde_json::json!({
"status": "ignored",
"note": "known",
"tags": ["noise"]
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let report = client
.get(format!(
"http://{proxy_addr}/api/runs/{}/report?format=markdown",
run.id
))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(review["status"], "ignored");
assert!(report.contains("# Moonlight Report"));
}
#[tokio::test]
async fn metrics_endpoint_reports_proxy_counters() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target(r#"{"source":"candidate"}"#).await;
let dir = tempdir().unwrap();
let config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap();
let metrics: MetricsSnapshot = client
.get(format!("http://{proxy_addr}/api/metrics"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(metrics.total_proxied_comparisons_started, 1);
assert_eq!(metrics.persisted_comparisons, 1);
assert_eq!(metrics.persistence_failures, 0);
assert_eq!(metrics.classifications.suspicious_differences, 1);
}
#[tokio::test]
async fn target_timeout_records_target_error() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate =
spawn_target_with_delay(r#"{"source":"candidate"}"#, Duration::from_millis(200)).await;
let dir = tempdir().unwrap();
let mut config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
config.target_timeout_ms = 25;
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap();
let run = wait_for_run(&client, proxy_addr).await;
assert_eq!(run.classification, Classification::TargetError);
}
#[tokio::test]
async fn wait_all_response_timing_waits_for_slow_candidate() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate =
spawn_target_with_delay(r#"{"source":"candidate"}"#, Duration::from_millis(175)).await;
let dir = tempdir().unwrap();
let config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let started = Instant::now();
let body = client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, r#"{"source":"primary"}"#);
assert!(started.elapsed() >= Duration::from_millis(150));
}
#[tokio::test]
async fn return_selected_response_timing_returns_before_slow_candidate_and_records_later() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate =
spawn_target_with_delay(r#"{"source":"candidate"}"#, Duration::from_millis(300)).await;
let dir = tempdir().unwrap();
let config = test_config(primary, candidate, &dir, ResponseTiming::ReturnSelected);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let started = Instant::now();
let body = client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, r#"{"source":"primary"}"#);
assert!(started.elapsed() < Duration::from_millis(250));
let run = wait_for_run(&client, proxy_addr).await;
assert_eq!(run.classification, Classification::SuspiciousDifference);
}
#[tokio::test]
async fn can_return_candidate_response() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target(r#"{"source":"candidate"}"#).await;
let dir = tempdir().unwrap();
let mut config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
config.return_target = ReturnTarget::Candidate;
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let body = client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, r#"{"source":"candidate"}"#);
}
#[tokio::test]
async fn candidate_return_can_fallback_to_primary() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target_with_status_and_delay(
StatusCode::INTERNAL_SERVER_ERROR,
"fail",
Duration::ZERO,
)
.await;
let dir = tempdir().unwrap();
let mut config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
config.return_target = ReturnTarget::Candidate;
config.return_fallback = ReturnFallback::Primary;
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let body = client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "fail");
}
#[tokio::test]
async fn candidate_transport_error_falls_back_to_primary_when_configured() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let dir = tempdir().unwrap();
let mut config = test_config(
primary,
"127.0.0.1:9".parse().unwrap(),
&dir,
ResponseTiming::WaitAll,
);
config.return_target = ReturnTarget::Candidate;
config.return_fallback = ReturnFallback::Primary;
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let body = client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, r#"{"source":"primary"}"#);
}
#[tokio::test]
async fn target_error_is_recorded_when_candidate_transport_fails() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let dir = tempdir().unwrap();
let config = test_config(
primary,
"127.0.0.1:9".parse().unwrap(),
&dir,
ResponseTiming::WaitAll,
);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let response = client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let run = wait_for_run(&client, proxy_addr).await;
assert_eq!(run.classification, Classification::TargetError);
}
#[tokio::test]
async fn admin_token_protects_admin_routes_but_not_health_or_proxy() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target(r#"{"source":"candidate"}"#).await;
let dir = tempdir().unwrap();
let mut config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
config.admin_token = Some("secret".to_string());
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let unauthorized = client
.get(format!("http://{proxy_addr}/api/runs"))
.send()
.await
.unwrap();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let unauthorized_metrics = client
.get(format!("http://{proxy_addr}/api/metrics"))
.send()
.await
.unwrap();
assert_eq!(unauthorized_metrics.status(), StatusCode::UNAUTHORIZED);
let health = client
.get(format!("http://{proxy_addr}/api/health"))
.send()
.await
.unwrap();
assert_eq!(health.status(), StatusCode::OK);
let proxy_body = client
.get(format!("http://{proxy_addr}/anything"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(proxy_body, r#"{"source":"primary"}"#);
let authorized = client
.get(format!("http://{proxy_addr}/api/runs"))
.bearer_auth("secret")
.send()
.await
.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
}
#[tokio::test]
async fn cors_allows_configured_origin_and_rejects_unlisted_origin() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target(r#"{"source":"candidate"}"#).await;
let dir = tempdir().unwrap();
let config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let allowed = client
.get(format!("http://{proxy_addr}/api/health"))
.header(header::ORIGIN, "http://127.0.0.1:5173")
.send()
.await
.unwrap();
assert_eq!(
allowed.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
Some(&HeaderValue::from_static("http://127.0.0.1:5173"))
);
let rejected = client
.get(format!("http://{proxy_addr}/api/health"))
.header(header::ORIGIN, "http://evil.test")
.send()
.await
.unwrap();
assert!(rejected
.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.is_none());
}
#[tokio::test]
async fn oversized_request_returns_payload_too_large() {
let primary = spawn_target(r#"{"source":"primary"}"#).await;
let candidate = spawn_target(r#"{"source":"candidate"}"#).await;
let dir = tempdir().unwrap();
let mut config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
config.max_request_body_bytes = 3;
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let response = client
.post(format!("http://{proxy_addr}/anything"))
.body("too large")
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn query_param_redaction_stores_redacted_query_but_forwards_original() {
let primary = spawn_uri_target().await;
let candidate = spawn_uri_target().await;
let dir = tempdir().unwrap();
let config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
let body = client
.get(format!(
"http://{proxy_addr}/anything?token=secret&safe=visible"
))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert_eq!(body, "/anything?token=secret&safe=visible");
let runs = fetch_runs(&client, proxy_addr).await;
assert!(matches!(
runs[0].input,
RunInput::Http { ref query, .. }
if query.as_deref() == Some("token=[redacted]&safe=visible")
));
}
#[tokio::test]
async fn json_path_pattern_redaction_applies_to_stored_previews() {
let primary = spawn_target(r#"{"items":[{"secret":"primary","visible":true}]}"#).await;
let candidate = spawn_target(r#"{"items":[{"secret":"candidate","visible":true}]}"#).await;
let dir = tempdir().unwrap();
let mut config = test_config(primary, candidate, &dir, ResponseTiming::WaitAll);
config
.redact_json_path_patterns
.push("$.items[*].secret".to_string());
let proxy_addr = spawn_proxy(config).await;
let client = reqwest::Client::new();
client
.post(format!("http://{proxy_addr}/anything"))
.body(r#"{"items":[{"secret":"request","visible":true}]}"#)
.send()
.await
.unwrap();
let run = wait_for_run(&client, proxy_addr).await;
let run: ComparisonRun = client
.get(format!("http://{proxy_addr}/api/runs/{}", run.id))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(!run.request_body.preview.contains("request"));
assert!(!run.primary.body.preview.contains("primary"));
assert!(!run.candidate.body.preview.contains("candidate"));
assert!(run
.request_body
.preview
.contains(r#""secret":"[redacted]""#));
assert!(run.primary.body.preview.contains(r#""visible":true"#));
}