mockforge-cli 0.3.234

CLI interface for MockForge
use super::*;
use clap::Parser;

struct Server {
    url: String,
    task: tokio::task::JoinHandle<()>,
}

impl Drop for Server {
    fn drop(&mut self) {
        self.task.abort();
    }
}

async fn serve(router: Router) -> Server {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let url = format!("http://{}", listener.local_addr().unwrap());
    let task = tokio::spawn(async move {
        axum::serve(listener, router).await.unwrap();
    });
    Server { url, task }
}

#[tokio::test]
async fn proxy_forwards_bytes_records_violations_and_enforces_only_strict_mode() {
    use axum::{extract::Path, http::StatusCode, response::IntoResponse, routing::post};
    use serde_json::json;
    use std::sync::{
        atomic::{AtomicUsize, Ordering},
        Arc,
    };
    let calls = Arc::new(AtomicUsize::new(0));
    let upstream = serve(
        Router::new()
            .route(
                "/items/{id}",
                post({
                    let calls = calls.clone();
                    move |Path(id): Path<String>| {
                        let calls = calls.clone();
                        async move {
                            calls.fetch_add(1, Ordering::SeqCst);
                            let body = match id.as_str() {
                                "invalid" => r#"{"created":"wrong type"}"#,
                                "malformed" => "not JSON",
                                _ => r#"{"created":true}"#,
                            };
                            ([("content-type", "application/json")], body).into_response()
                        }
                    }
                }),
            )
            .route(
                "/blob",
                get(|| async {
                    ([("content-type", "application/octet-stream")], vec![0, 255, 128, 42])
                        .into_response()
                }),
            )
            .route(
                "/redirect",
                get(|| async {
                    (StatusCode::FOUND, [("location", "/blob")], "redirect body").into_response()
                }),
            ),
    )
    .await;
    let spec = json!({"openapi":"3.0.0","info":{"title":"Proxy fixture","version":"1"},"paths":{
        "/items/{id}":{"post":{
            "parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},
                {"name":"X-Required","in":"header","required":true,"schema":{"type":"string"}},
                {"name":"session","in":"cookie","required":true,"schema":{"type":"string","enum":["fixture"]}}],
            "requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}}}},
            "responses":{"2XX":{"description":"created","content":{"application/json":{"schema":{"type":"object","required":["created"],"properties":{"created":{"type":"boolean"}}}}}}}
        }},
        "/blob":{"get":{"responses":{"200":{"description":"binary","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},
        "/redirect":{"get":{"responses":{"302":{"description":"redirect"}}}}
    }});
    let args = |strict| ProxyArgs {
        upstream: upstream.url.parse().unwrap(),
        host: "127.0.0.1".parse().unwrap(),
        port: 0,
        spec: None,
        validate_conformance: true,
        validate_conformance_strict: strict,
    };
    conformance_violations::clear();
    let passive = serve(build_router(&args(false), Some(spec.clone())).unwrap()).await;
    let strict = serve(build_router(&args(true), Some(spec)).unwrap()).await;
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(3))
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .unwrap();
    let response = client
        .post(format!("{}/items/invalid", passive.url))
        .json(&json!({}))
        .send()
        .await
        .unwrap();
    assert_eq!(response.status(), 200);
    assert_eq!(response.text().await.unwrap(), r#"{"created":"wrong type"}"#);
    assert_eq!(calls.load(Ordering::SeqCst), 1);
    let rows: serde_json::Value = client
        .get(format!("{}/__mockforge/api/conformance/violations", passive.url))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    assert!(rows["violations"]
        .as_array()
        .unwrap()
        .iter()
        .any(|r| r["category"] == "response-shape"));
    assert!(rows["violations"]
        .as_array()
        .unwrap()
        .iter()
        .any(|r| r["category"] != "response-shape"));
    let binary = client.get(format!("{}/blob", passive.url)).send().await.unwrap();
    assert_eq!(binary.bytes().await.unwrap().as_ref(), &[0, 255, 128, 42]);
    let redirect = client.get(format!("{}/redirect", passive.url)).send().await.unwrap();
    assert_eq!(redirect.status(), 302);
    assert_eq!(redirect.headers()["location"], "/blob");
    assert_eq!(redirect.text().await.unwrap(), "redirect body");
    let rejected = client
        .post(format!("{}/items/valid", strict.url))
        .json(&json!({}))
        .send()
        .await
        .unwrap();
    assert_eq!(rejected.status(), 400);
    assert_eq!(
        calls.load(Ordering::SeqCst),
        1,
        "invalid strict request must not reach upstream"
    );
    let missing_cookie = client
        .post(format!("{}/items/valid", strict.url))
        .header("X-Required", "fixture")
        .json(&json!({"name":"widget"}))
        .send()
        .await
        .unwrap();
    assert_eq!(missing_cookie.status(), 400);
    assert_eq!(calls.load(Ordering::SeqCst), 1);
    for id in ["invalid", "malformed"] {
        let response = client
            .post(format!("{}/items/{id}", strict.url))
            .header("X-Required", "fixture")
            .header("Cookie", "session=\"fixture\"")
            .json(&json!({"name":"widget"}))
            .send()
            .await
            .unwrap();
        assert_eq!(response.status(), 502);
    }
    let valid = client
        .post(format!("{}/items/valid", strict.url))
        .header("X-Required", "fixture")
        .header("Cookie", "session=\"fixture\"")
        .json(&json!({"name":"widget"}))
        .send()
        .await
        .unwrap();
    assert_eq!(valid.status(), 200, "2XX response range must accept a valid 200 response");
    assert_eq!(valid.json::<serde_json::Value>().await.unwrap(), json!({"created":true}));
    assert_eq!(calls.load(Ordering::SeqCst), 4);
}

#[test]
fn proxy_cli_requires_valid_upstream_and_validation_spec() {
    #[derive(Parser)]
    struct Arguments {
        #[command(flatten)]
        args: ProxyArgs,
    }
    let cli = Arguments::try_parse_from([
        "proxy",
        "--upstream",
        "http://127.0.0.1:9090",
        "--validate-conformance-strict",
    ])
    .unwrap();
    assert!(build_router(&cli.args, None).is_err());
    let cli = Arguments::try_parse_from(["proxy", "--upstream", "file:///tmp/upstream"]).unwrap();
    assert!(build_router(&cli.args, None).is_err());
}