//! G3 — CLI↔HTTP↔MCP contract test. //! //! For each fixture tool, invoke via: //! Tier A — Registry directly (reference) //! Tier B — HTTP daemon (axum::serve on 127.0.0.1:0) //! Tier C — MCP server (handle_request in-process) //! //! Assert: //! 1. All tiers that succeed produce equal normalised payloads //! 2. The payload validates against the listing output schema //! 3. All tiers that fail produce the same error code //! //! The normaliser strips volatile fields (trace_id, timestamp, …) so the //! contract is on semantic equivalence, not byte-for-byte equality. use contract_tests::{ listing_output_schema, make_fixture_registry, normalise, tier_http, tier_mcp, tier_registry, }; use jsonschema::Validator; use provisioning_mcp_server::registry_server::McpServer; use provisioning_core::Environment; use provisioning_daemon::{AppState, http::router}; use serde_json::{Value, json}; use std::sync::{Arc, OnceLock}; // ── Test harness ────────────────────────────────────────────────────────────── static CRYPTO: OnceLock<()> = OnceLock::new(); fn init_crypto() { CRYPTO.get_or_init(|| { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); }); } struct Harness { env: Arc, registry: Arc, mcp: McpServer, http_base: String, _daemon_task: tokio::task::JoinHandle<()>, } async fn boot() -> Harness { init_crypto(); // Shared fixture registry for all tiers. let registry = Arc::new(make_fixture_registry()); let env = Arc::new(Environment::default()); // Tier B — bind daemon on ephemeral port. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("local_addr"); let http_base = format!("http://{addr}"); // AppState owns its own Arc; we build it from a fresh fixture // registry so the daemon and the reference tier see identical tool sets. let daemon_registry = make_fixture_registry(); let app = AppState::new(daemon_registry, Environment::default()); let app_router = router(app); let task = tokio::spawn(async move { axum::serve(listener, app_router).await.expect("daemon serve"); }); // Wait for the daemon to accept connections (fast — local TCP). for _ in 0..40 { if reqwest::get(format!("{}/health", http_base)).await.is_ok() { break; } tokio::time::sleep(std::time::Duration::from_millis(25)).await; } // Tier C — in-process MCP server with its own fixture registry. let mcp = McpServer::new(make_fixture_registry(), Environment::default(), "g3-test", "0"); Harness { env, registry, mcp, http_base, _daemon_task: task } } fn validate_listing(v: &Value) { let schema = listing_output_schema(); let validator = Validator::new(&schema).expect("compile schema"); validator .validate(v) .unwrap_or_else(|errors| panic!("listing schema validation failed: {errors}")); } // ── Success contract ────────────────────────────────────────────────────────── #[tokio::test] async fn listing_tool_agrees_across_three_tiers() { let h = boot().await; let params = json!({}); let a = tier_registry(&h.registry, h.env.clone(), "ct_listing", params.clone()).await; let b = tier_http(&h.http_base, "ct_listing", params.clone()).await; let c = tier_mcp(&h.mcp, "ct_listing", params.clone()).await; let a_payload = a.payload.expect("tier A registry must succeed"); let b_payload = b.payload.expect("tier B http must succeed"); let c_payload = c.payload.expect("tier C mcp must succeed"); validate_listing(&a_payload); validate_listing(&b_payload); validate_listing(&c_payload); assert_eq!(normalise(&a_payload), normalise(&b_payload), "A↔B diverge"); assert_eq!(normalise(&a_payload), normalise(&c_payload), "A↔C diverge"); assert_eq!(normalise(&b_payload), normalise(&c_payload), "B↔C diverge"); } #[tokio::test] async fn echo_tool_agrees_across_three_tiers() { let h = boot().await; let params = json!({ "message": "contract" }); let a = tier_registry(&h.registry, h.env.clone(), "ct_echo", params.clone()).await; let b = tier_http(&h.http_base, "ct_echo", params.clone()).await; let c = tier_mcp(&h.mcp, "ct_echo", params.clone()).await; let a_payload = a.payload.expect("A must succeed"); let b_payload = b.payload.expect("B must succeed"); let c_payload = c.payload.expect("C must succeed"); validate_listing(&a_payload); validate_listing(&b_payload); validate_listing(&c_payload); assert_eq!(normalise(&a_payload), normalise(&b_payload)); assert_eq!(normalise(&a_payload), normalise(&c_payload)); // Exact content check — echo should round-trip the message assert_eq!(a_payload["items"][0]["message"], "contract"); assert_eq!(b_payload["items"][0]["message"], "contract"); assert_eq!(c_payload["items"][0]["message"], "contract"); } // ── Failure contract ────────────────────────────────────────────────────────── #[tokio::test] async fn invalid_param_error_code_agrees_across_tiers() { let h = boot().await; let params = json!({}); // echo requires "message" → InvalidParam let a = tier_registry(&h.registry, h.env.clone(), "ct_echo", params.clone()).await; let b = tier_http(&h.http_base, "ct_echo", params.clone()).await; let c = tier_mcp(&h.mcp, "ct_echo", params.clone()).await; // All three must classify as InvalidParam → -32602 assert_eq!(a.error_code, Some(-32602), "A: {:?}", a.error_message); assert_eq!(b.error_code, Some(-32602), "B: {:?}", b.error_message); assert_eq!(c.error_code, Some(-32602), "C: {:?}", c.error_message); } #[tokio::test] async fn failing_tool_error_code_agrees_across_tiers() { let h = boot().await; let params = json!({}); let a = tier_registry(&h.registry, h.env.clone(), "ct_fail", params.clone()).await; let b = tier_http(&h.http_base, "ct_fail", params.clone()).await; let c = tier_mcp(&h.mcp, "ct_fail", params.clone()).await; assert_eq!(a.error_code, Some(-32602)); assert_eq!(b.error_code, Some(-32602)); assert_eq!(c.error_code, Some(-32602)); } // ── tools/list contract ─────────────────────────────────────────────────────── #[tokio::test] async fn tools_list_count_agrees_across_surfaces() { let h = boot().await; // Registry baseline let reg_count = h.registry.len(); // HTTP let http_body: Value = reqwest::get(format!("{}/api/v1/tools", h.http_base)) .await .expect("http list") .json() .await .expect("parse"); let http_count = http_body["tools"].as_array().expect("tools array").len(); // MCP let rpc = json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}); let mcp_resp = h.mcp.handle_request(rpc).await; let mcp_count = mcp_resp["result"]["tools"].as_array().expect("mcp tools").len(); assert_eq!(reg_count, http_count, "HTTP count diverges from registry"); assert_eq!(reg_count, mcp_count, "MCP count diverges from registry"); }