195 lines
7.5 KiB
Rust
195 lines
7.5 KiB
Rust
|
|
//! 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");
|
||
|
|
}
|