#![allow(clippy::unwrap_used, clippy::expect_used)]
use secrecy::SecretString;
use serde_json::json;
use wiremock::MockServer;
use crate::mcp_source::source::{dispatch_route, parse_rows};
use crate::mcp_source::types::{McpRoute, Transport};
use super::catalog::{mount_admin, tenant};
#[tokio::test]
async fn route_debug_redacts_token() {
let route = McpRoute {
server_id: "s1".to_string(),
transport_url: "https://mcp.example.com/".to_string(),
auth_header_name: None,
auth_token: Some(SecretString::from("tok-secret".to_string())),
raw_tool_name: "get_issue".to_string(),
transport: Transport::Http,
component_ref: None,
component_version: None,
component_digest: None,
};
let dbg = format!("{route:?}");
assert!(!dbg.contains("tok-secret"), "got: {dbg}");
assert!(dbg.contains("[REDACTED]"), "got: {dbg}");
}
#[test]
fn parse_rows_defaults_transport_to_http_and_reads_local_wasm() {
let body = json!({"servers": [
{ "id": "h", "name": "H", "transport_url": "https://x/", "auth_header_name": null,
"auth_token": null, "allowed_tools": null, "roles": ["agentic_worker"] },
{ "id": "l", "name": "L", "transport_url": "", "auth_header_name": null,
"auth_token": null, "allowed_tools": null, "roles": ["agentic_worker"],
"transport": "local-wasm", "component_ref": "weather.component",
"component_version": "1.0.0" }
]});
let rows = parse_rows(body).expect("parse");
assert!(matches!(rows[0].transport, Transport::Http));
assert!(matches!(rows[1].transport, Transport::LocalWasm));
assert_eq!(rows[1].component_ref.as_deref(), Some("weather.component"));
}
#[test]
fn parse_rows_accepts_null_transport_url_for_local_wasm() {
let body = json!({"servers": [
{ "id": "h", "name": "H", "transport_url": "https://x/", "auth_header_name": null,
"auth_token": null, "allowed_tools": null, "roles": ["agentic_worker"] },
{ "id": "l", "name": "L", "transport_url": null, "auth_header_name": null,
"auth_token": null, "allowed_tools": null, "roles": ["agentic_worker"],
"transport": "local-wasm", "component_ref": "weather.component",
"component_version": "1.0.0" }
]});
let rows = parse_rows(body).expect("null transport_url must not break the list");
assert_eq!(
rows.len(),
2,
"the http row must survive alongside the local-wasm row"
);
assert_eq!(rows[0].transport_url, "https://x/");
assert!(matches!(rows[1].transport, Transport::LocalWasm));
assert_eq!(rows[1].transport_url, "", "null maps to empty string");
}
#[test]
fn parse_rows_carries_component_version_and_digest() {
let body = json!({"servers": [
{ "id": "h", "name": "H", "transport_url": "https://x/", "auth_header_name": null,
"auth_token": null, "allowed_tools": null, "roles": ["agentic_worker"] },
{ "id": "l", "name": "L", "transport_url": "", "auth_header_name": null,
"auth_token": null, "allowed_tools": null, "roles": ["agentic_worker"],
"transport": "local-wasm", "component_ref": "weather.component",
"component_version": "1.0.0",
"component_digest": "abc123deadbeef" }
]});
let rows = parse_rows(body).expect("parse");
assert!(rows[0].component_version.is_none());
assert!(rows[0].component_digest.is_none());
assert_eq!(rows[1].component_version.as_deref(), Some("1.0.0"));
assert_eq!(rows[1].component_digest.as_deref(), Some("abc123deadbeef"));
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial_test::serial]
async fn local_wasm_server_lists_and_dispatches_in_process() {
use crate::mcp_source::source::McpToolSource;
let src = std::env::var("GREENTIC_MCP_ROUTER_ECHO_WASM")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../greentic-mcp/target/wasm32-wasip2/release/router_echo.wasm")
});
if !src.exists() {
return;
}
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("GREENTIC_MCP_LOCAL_CACHE_DIR", dir.path()) };
std::fs::copy(&src, dir.path().join("router_echo.wasm")).unwrap();
let admin = MockServer::start().await;
let body = json!({ "servers": [{
"id": "local", "name": "Local", "transport_url": "",
"auth_header_name": null, "auth_token": null, "allowed_tools": null,
"roles": ["agentic_worker"], "transport": "local-wasm",
"component_ref": "router_echo", "component_version": "1.0.0",
"component_digest": "0000000000000000000000000000000000000000000000000000000000000000"
}]});
mount_admin(&admin, body).await;
let source = McpToolSource::new(admin.uri(), "gtc_live_x");
let catalog = source.catalog(&tenant()).await;
assert!(
catalog.tool_entry("local", "echo").is_some(),
"echo tool must be listed"
);
let route = catalog
.route("local", "echo")
.expect("route for echo must exist");
let out = dispatch_route(route, "{\"message\":\"hi\"}").await;
assert!(
!out.to_string().contains("\"error\""),
"dispatch must succeed; got: {out}"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial_test::serial]
async fn lazy_pull_on_catalog_miss_and_dispatch() {
use crate::mcp_source::source::McpToolSource;
use crate::mcp_store_pull::{
STORE_TOKEN_ENV, STORE_URL_ENV, TRUSTED_SIGNERS_ENV,
fixtures::{
build_gtxpack, fixture_wasm, hex_sha256, pubkey_env_value, sample_describe,
sign_describe_like_store,
},
};
use ed25519_dalek::SigningKey;
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let Some(wasm_src) = fixture_wasm() else {
return; };
let wasm_bytes = std::fs::read(&wasm_src).unwrap();
let signing_key = SigningKey::from_bytes(&[20u8; 32]);
let signed_describe = sign_describe_like_store(&sample_describe(), &signing_key);
let archive = build_gtxpack(&signed_describe, &wasm_bytes);
let gtxpack_digest = hex_sha256(&archive);
let store_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/api/v1/extensions/router_echo/1.0.0/artifact"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/octet-stream")
.set_body_bytes(archive.clone()),
)
.mount(&store_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("GREENTIC_MCP_LOCAL_CACHE_DIR", cache_dir.path());
std::env::set_var(STORE_URL_ENV, store_server.uri());
std::env::set_var(TRUSTED_SIGNERS_ENV, pubkey_env_value(&signing_key));
std::env::remove_var(STORE_TOKEN_ENV);
}
let admin = MockServer::start().await;
let body = json!({ "servers": [{
"id": "local", "name": "Local", "transport_url": "",
"auth_header_name": null, "auth_token": null, "allowed_tools": null,
"roles": ["agentic_worker"], "transport": "local-wasm",
"component_ref": "router_echo", "component_version": "1.0.0",
"component_digest": gtxpack_digest
}]});
mount_admin(&admin, body).await;
let source = McpToolSource::new(admin.uri(), "gtc_live_x");
let catalog = source.catalog(&tenant()).await;
let wasm_in_cache = cache_dir.path().join("router_echo.wasm");
let sidecar_in_cache = cache_dir.path().join("router_echo.wasm.sha256");
assert!(
wasm_in_cache.exists(),
"wasm must be in cache after lazy pull"
);
assert!(
sidecar_in_cache.exists(),
"wasm digest sidecar must be written"
);
assert!(
catalog.tool_entry("local", "echo").is_some(),
"echo tool must be listed after lazy pull"
);
let route = catalog.route("local", "echo").expect("route for echo");
let out = dispatch_route(route, "{\"message\":\"world\"}").await;
assert!(
!out.to_string().contains("\"error\""),
"dispatch must succeed after lazy pull; got: {out}"
);
std::fs::remove_file(&wasm_in_cache).unwrap();
std::fs::remove_file(&sidecar_in_cache).unwrap();
let store_server2 = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/api/v1/extensions/router_echo/1.0.0/artifact"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/octet-stream")
.set_body_bytes(archive),
)
.mount(&store_server2)
.await;
unsafe { std::env::set_var(STORE_URL_ENV, store_server2.uri()) };
let admin2 = MockServer::start().await;
let wrong_digest = "f".repeat(64); let body2 = json!({ "servers": [{
"id": "local", "name": "Local", "transport_url": "",
"auth_header_name": null, "auth_token": null, "allowed_tools": null,
"roles": ["agentic_worker"], "transport": "local-wasm",
"component_ref": "router_echo", "component_version": "1.0.0",
"component_digest": wrong_digest
}]});
mount_admin(&admin2, body2).await;
let source2 = McpToolSource::new(admin2.uri(), "gtc_live_x");
let catalog2 = source2.catalog(&tenant()).await;
assert!(
catalog2.is_empty(),
"wrong component_digest must degrade to empty catalog"
);
unsafe {
std::env::remove_var("GREENTIC_MCP_LOCAL_CACHE_DIR");
std::env::remove_var(STORE_URL_ENV);
std::env::remove_var(TRUSTED_SIGNERS_ENV);
}
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial_test::serial]
async fn dispatch_route_local_wasm_wrong_digest_returns_error_not_panic() {
use crate::mcp_store_pull::{
STORE_TOKEN_ENV, STORE_URL_ENV, TRUSTED_SIGNERS_ENV,
fixtures::{build_gtxpack, pubkey_env_value, sample_describe, sign_describe_like_store},
};
use ed25519_dalek::SigningKey;
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let wasm_bytes = b"not-a-real-wasm-component".to_vec();
let signing_key = SigningKey::from_bytes(&[30u8; 32]);
let signed_describe = sign_describe_like_store(&sample_describe(), &signing_key);
let archive = build_gtxpack(&signed_describe, &wasm_bytes);
let wrong_digest = "e".repeat(64);
let store_server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/api/v1/extensions/router_echo/1.0.0/artifact"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/octet-stream")
.set_body_bytes(archive),
)
.mount(&store_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("GREENTIC_MCP_LOCAL_CACHE_DIR", cache_dir.path());
std::env::set_var(STORE_URL_ENV, store_server.uri());
std::env::set_var(TRUSTED_SIGNERS_ENV, pubkey_env_value(&signing_key));
std::env::remove_var(STORE_TOKEN_ENV);
}
let route = McpRoute {
server_id: "local".to_string(),
transport_url: String::new(),
auth_header_name: None,
auth_token: None,
raw_tool_name: "echo".to_string(),
transport: Transport::LocalWasm,
component_ref: Some("router_echo".to_string()),
component_version: Some("1.0.0".to_string()),
component_digest: Some(wrong_digest),
};
let result = dispatch_route(&route, r#"{"message":"degrade-test"}"#).await;
unsafe {
std::env::remove_var("GREENTIC_MCP_LOCAL_CACHE_DIR");
std::env::remove_var(STORE_URL_ENV);
std::env::remove_var(TRUSTED_SIGNERS_ENV);
}
assert!(
result.to_string().contains("\"error\""),
"dispatch_route with wrong digest must degrade to {{\"error\": ...}}; got: {result}"
);
assert!(
!cache_dir.path().join("router_echo.wasm").exists(),
"a digest mismatch during dispatch must leave the cache empty"
);
}