prvng_platform/crates/contract-tests/tests/g3_contract.rs

195 lines
7.5 KiB
Rust
Raw Normal View History

//! 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<Environment>,
registry: Arc<provisioning_core::Registry>,
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<Registry>; 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");
}