Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions crates/iris-cli/tests/configured_instance_boundaries.rs
Original file line number Diff line number Diff line change
@@ -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");
}
37 changes: 37 additions & 0 deletions crates/iris-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn MessageProvider>,
support.clone() as Arc<dyn MessageProvider>,
],
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()
Expand Down
16 changes: 15 additions & 1 deletion crates/iris-providers/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<dyn AuditLog>>,
store: Option<Arc<dyn iris_core::AttachmentStore>>,
outbound: std::sync::Mutex<Vec<RecordedSend>>,
Expand All @@ -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<String>) -> 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<dyn AuditLog>) -> Self {
Self {
instance_id: METADATA.id.to_owned(),
audit: Some(audit),
store: None,
outbound: std::sync::Mutex::new(Vec::new()),
Expand Down Expand Up @@ -118,6 +128,10 @@ impl MessageProvider for MockProvider {
&METADATA
}

fn id(&self) -> &str {
&self.instance_id
}

async fn list_threads(&self, limit: Option<u32>) -> Result<Vec<Thread>> {
let threads = vec![Thread {
id: Uuid::new_v4(),
Expand Down
126 changes: 125 additions & 1 deletion crates/iris-server/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,8 @@ mod tests {
}

struct FakeProvider {
/// Configured instance ID; static type remains `metadata.id`.
instance_id: String,
metadata: ProviderMetadata,
threads: Vec<Thread>,
contacts: Vec<Contact>,
Expand All @@ -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,
Expand Down Expand Up @@ -837,13 +848,23 @@ mod tests {
&self.metadata
}

fn id(&self) -> &str {
&self.instance_id
}

fn realtime_status(&self) -> RealtimeStatus {
self.realtime_status.clone()
}

async fn list_threads(&self, limit: Option<u32>) -> iris_core::Result<Vec<Thread>> {
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);
}
Expand Down Expand Up @@ -877,6 +898,9 @@ mod tests {
async fn list_contacts(&self, limit: Option<u32>) -> iris_core::Result<Vec<Contact>> {
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);
}
Expand Down Expand Up @@ -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<dyn MessageProvider>,
support.clone() as Arc<dyn MessageProvider>,
],
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<Thread> =
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<_>>(),
vec![Some("email.support"), Some("email.ops")]
);
let contacts = app
.clone()
.oneshot(Request::get("/contacts").body(Body::empty()).unwrap())
.await
.unwrap();
let contacts: Vec<Contact> =
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<_>>(),
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();
Expand Down
Loading