From db21ed9a79eaea3c092fbbc90cb08bf8cc0f920c Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Sun, 6 Sep 2026 08:15:52 -0600 Subject: [PATCH 1/3] test: verify named provider instances at HTTP boundary (COD-460) --- crates/iris-server/src/routes.rs | 126 ++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/crates/iris-server/src/routes.rs b/crates/iris-server/src/routes.rs index 4d0beb4..339d9f2 100644 --- a/crates/iris-server/src/routes.rs +++ b/crates/iris-server/src/routes.rs @@ -760,6 +760,8 @@ mod tests { } struct FakeProvider { + /// Configured instance ID; static type remains `metadata.id`. + instance_id: String, metadata: ProviderMetadata, threads: Vec, contacts: Vec, @@ -771,9 +773,18 @@ mod tests { impl FakeProvider { fn new(id: &'static str, name: &'static str) -> Self { + Self::new_instance(id, id, name) + } + + fn new_instance( + instance_id: &str, + provider_type: &'static str, + name: &'static str, + ) -> Self { Self { + instance_id: instance_id.to_owned(), metadata: ProviderMetadata { - id, + id: provider_type, name, capabilities: &[ ProviderCapability::ListThreads, @@ -837,6 +848,10 @@ mod tests { &self.metadata } + fn id(&self) -> &str { + &self.instance_id + } + fn realtime_status(&self) -> RealtimeStatus { self.realtime_status.clone() } @@ -844,6 +859,12 @@ mod tests { async fn list_threads(&self, limit: Option) -> iris_core::Result> { self.maybe_fail("list_threads")?; let mut threads = self.threads.clone(); + for thread in &mut threads { + thread.provider_instance = Some(self.instance_id.clone()); + for participant in &mut thread.participants { + participant.provider_instance = Some(self.instance_id.clone()); + } + } if let Some(limit) = limit { threads.truncate(limit as usize); } @@ -877,6 +898,9 @@ mod tests { async fn list_contacts(&self, limit: Option) -> iris_core::Result> { self.maybe_fail("list_contacts")?; let mut contacts = self.contacts.clone(); + for contact in &mut contacts { + contact.provider_instance = Some(self.instance_id.clone()); + } if let Some(limit) = limit { contacts.truncate(limit as usize); } @@ -1226,6 +1250,106 @@ mod tests { ); } + #[tokio::test] + async fn http_surfaces_discover_and_route_same_type_instances() { + let ops_thread = Uuid::new_v4(); + let support_thread = Uuid::new_v4(); + let ops = Arc::new( + FakeProvider::new_instance("email.ops", "email", "Operations Email") + .with_threads(vec![thread(ops_thread, "email", 15)]) + .with_contacts(vec![contact("email", "ops", "Operations")]), + ); + let support = Arc::new( + FakeProvider::new_instance("email.support", "email", "Support Email") + .with_threads(vec![thread(support_thread, "email", 16)]) + .with_contacts(vec![contact("email", "support", "Support")]), + ); + let app_state = AppState { + providers: vec![ + ops.clone() as Arc, + support.clone() as Arc, + ], + thread_owners: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + attachments: Arc::new(NullStore), + audit: Arc::new(iris_audit::LocalFsAuditLog::new( + "/tmp/iris-server-test-audit", + )), + ingest: None, + ingest_sources: std::collections::BTreeSet::new(), + ingest_secrets: std::collections::BTreeMap::new(), + sse: crate::sse::SseSettings::default(), + }; + let app = router(app_state); + let providers = app + .clone() + .oneshot(Request::get("/providers").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(providers.status(), StatusCode::OK); + let providers: serde_json::Value = + serde_json::from_slice(&to_bytes(providers.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!( + providers, + serde_json::json!([ + {"id":"email.ops","provider_type":"email","name":"Operations Email","capabilities":["list_threads","list_messages","list_contacts","send_messages"]}, + {"id":"email.support","provider_type":"email","name":"Support Email","capabilities":["list_threads","list_messages","list_contacts","send_messages"]} + ]) + ); + let threads = app + .clone() + .oneshot(Request::get("/threads").body(Body::empty()).unwrap()) + .await + .unwrap(); + let threads: Vec = + serde_json::from_slice(&to_bytes(threads.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!( + threads + .iter() + .map(|thread| thread.provider_instance.as_deref()) + .collect::>(), + vec![Some("email.support"), Some("email.ops")] + ); + let contacts = app + .clone() + .oneshot(Request::get("/contacts").body(Body::empty()).unwrap()) + .await + .unwrap(); + let contacts: Vec = + serde_json::from_slice(&to_bytes(contacts.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!( + contacts + .iter() + .map(|contact| contact.provider_instance.as_deref()) + .collect::>(), + vec![Some("email.ops"), Some("email.support")] + ); + let request = Request::post(format!("/messages/{ops_thread}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"body":"only ops","provider":"email.ops"}"#)) + .unwrap(); + assert_eq!( + app.clone().oneshot(request).await.unwrap().status(), + StatusCode::OK + ); + assert_eq!(ops.recorded_outbound().len(), 1); + assert!(support.recorded_outbound().is_empty()); + let request = Request::post(format!("/messages/{support_thread}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"body":"no dispatch","provider":"email.missing"}"#, + )) + .unwrap(); + assert_eq!( + app.oneshot(request).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + assert_eq!(ops.recorded_outbound().len(), 1); + assert!(support.recorded_outbound().is_empty()); + } + #[tokio::test] async fn list_messages_routes_to_owning_provider() { let telegram_thread = Uuid::new_v4(); From ffb1c8c262d1518103c8b68a30e626373048090f Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Sun, 6 Sep 2026 09:25:35 -0600 Subject: [PATCH 2/3] test: cover named instance MCP routing (COD-460) --- crates/iris-mcp/src/lib.rs | 37 +++++++++++++++++++++++++++++++ crates/iris-providers/src/mock.rs | 16 ++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/iris-mcp/src/lib.rs b/crates/iris-mcp/src/lib.rs index 32da1c9..541d1fa 100644 --- a/crates/iris-mcp/src/lib.rs +++ b/crates/iris-mcp/src/lib.rs @@ -616,6 +616,43 @@ mod tests { ); } + #[tokio::test] + async fn generated_mcp_send_routes_to_discovered_named_instance() { + let ops = Arc::new(MockProvider::new().with_instance_id("mock.ops")); + let support = Arc::new(MockProvider::new().with_instance_id("mock.support")); + let server = McpServer::new( + vec![ + ops.clone() as Arc, + support.clone() as Arc, + ], + Arc::new(iris_audit::LocalFsAuditLog::new("/tmp/iris-mcp-test-audit")), + ); + let request = |provider: &str| { + json!({ + "jsonrpc": "2.0", "id": 10, "method": "tools/call", + "params": {"name": "send_message", "arguments": { + "thread_id": "11111111-1111-1111-1111-111111111111", + "body": "named route", "provider": provider + }} + }) + }; + let sent = server.handle_jsonrpc(request("mock.ops")).await; + assert_eq!(sent["error"], Value::Null, "{sent}"); + assert_eq!(ops.recorded_sends().unwrap().len(), 1); + assert!(support.recorded_sends().unwrap().is_empty()); + + let missing = server.handle_jsonrpc(request("mock.missing")).await; + assert_ne!(missing["error"], Value::Null, "{missing}"); + assert!( + missing["error"]["message"] + .as_str() + .unwrap() + .contains("provider") + ); + assert_eq!(ops.recorded_sends().unwrap().len(), 1); + assert!(support.recorded_sends().unwrap().is_empty()); + } + #[tokio::test] async fn send_message_tool_rejects_mixed_attachment_union() { let response = server() diff --git a/crates/iris-providers/src/mock.rs b/crates/iris-providers/src/mock.rs index dd5b5fa..ee2a8c6 100644 --- a/crates/iris-providers/src/mock.rs +++ b/crates/iris-providers/src/mock.rs @@ -29,6 +29,7 @@ const METADATA: ProviderMetadata = ProviderMetadata { /// A simple mock provider that returns static test data. #[derive(Debug, Default)] pub struct MockProvider { + instance_id: String, audit: Option>, store: Option>, outbound: std::sync::Mutex>, @@ -48,18 +49,27 @@ pub struct RecordedSend { impl MockProvider { /// Creates a mock provider without audit instrumentation. #[must_use] - pub const fn new() -> Self { + pub fn new() -> Self { Self { + instance_id: METADATA.id.to_owned(), audit: None, store: None, outbound: std::sync::Mutex::new(Vec::new()), } } + /// Assign a configured instance ID while retaining the `mock` provider type. + #[must_use] + pub fn with_instance_id(mut self, instance_id: impl Into) -> Self { + self.instance_id = instance_id.into(); + self + } + /// Creates a mock provider that records operation metadata in `audit`. #[must_use] pub fn with_audit(audit: Arc) -> Self { Self { + instance_id: METADATA.id.to_owned(), audit: Some(audit), store: None, outbound: std::sync::Mutex::new(Vec::new()), @@ -118,6 +128,10 @@ impl MessageProvider for MockProvider { &METADATA } + fn id(&self) -> &str { + &self.instance_id + } + async fn list_threads(&self, limit: Option) -> Result> { let threads = vec![Thread { id: Uuid::new_v4(), From fef118618798d879d0fbb816ae0b8869e323f563 Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Sun, 6 Sep 2026 10:31:14 -0600 Subject: [PATCH 3/3] test: cover named instance CLI routing (COD-460) --- .../tests/configured_instance_boundaries.rs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 crates/iris-cli/tests/configured_instance_boundaries.rs diff --git a/crates/iris-cli/tests/configured_instance_boundaries.rs b/crates/iris-cli/tests/configured_instance_boundaries.rs new file mode 100644 index 0000000..7458fab --- /dev/null +++ b/crates/iris-cli/tests/configured_instance_boundaries.rs @@ -0,0 +1,88 @@ +use std::process::{Command, Output}; + +fn iris_command(config: &std::path::Path, audit: &std::path::Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_iris")); + command + .env("IRIS_CONFIG", config) + .env("IRIS_AUDIT_DIR", audit) + .env("IRIS_ATTACHMENT_DIR", audit.join("attachments")); + command +} + +fn run(command: &mut Command) -> Output { + command.output().expect("iris CLI process starts") +} + +#[test] +fn generated_cli_discovers_and_routes_to_named_mock_instances() { + let temp = tempfile::tempdir().expect("temporary test directory"); + let config = temp.path().join("iris.toml"); + let audit = temp.path().join("audit"); + std::fs::write( + &config, + "[providers.mock.instances.ops]\n[providers.mock.instances.support]\n", + ) + .expect("write named mock config"); + + let providers = run(iris_command(&config, &audit).arg("providers")); + assert!( + providers.status.success(), + "providers failed: {}", + String::from_utf8_lossy(&providers.stderr) + ); + let providers = String::from_utf8(providers.stdout).expect("providers output is UTF-8"); + assert!(providers.contains("mock.ops (mock)"), "{providers}"); + assert!(providers.contains("mock.support (mock)"), "{providers}"); + let discovered_ops = providers + .lines() + .filter_map(|line| line.trim().split_once(" (")) + .map(|(instance_id, _)| instance_id) + .find(|instance_id| *instance_id == "mock.ops") + .expect("providers output exposes mock.ops") + .to_owned(); + + let sent = run(iris_command(&config, &audit).args([ + "send-message", + "--body", + "to ops", + "--provider", + &discovered_ops, + "thread-1", + ])); + assert!( + sent.status.success(), + "explicit send failed: {}", + String::from_utf8_lossy(&sent.stderr) + ); + + let unknown = run(iris_command(&config, &audit).args([ + "send-message", + "--body", + "must not dispatch", + "--provider", + "mock.unknown", + "thread-1", + ])); + assert!( + !unknown.status.success(), + "unknown instance unexpectedly sent" + ); + assert!( + String::from_utf8_lossy(&unknown.stderr).contains("provider not available: mock.unknown"), + "{}", + String::from_utf8_lossy(&unknown.stderr) + ); + + let audit = run(iris_command(&config, &audit).args(["audit-query", "--action", "send"])); + assert!( + audit.status.success(), + "audit query failed: {}", + String::from_utf8_lossy(&audit.stderr) + ); + let entries: serde_json::Value = + serde_json::from_slice(&audit.stdout).expect("audit-query emits JSON"); + let entries = entries.as_array().expect("audit entries array"); + assert_eq!(entries.len(), 1, "{entries:?}"); + assert_eq!(entries[0]["event"]["provider"], "mock.ops"); + assert_eq!(entries[0]["event"]["source_id"], "thread-1"); +}