#![cfg(feature = "server")]
use axon::cancel_token::CancellationFlag;
use axon::flow_dispatcher::{dispatch_node, DispatchCtx};
use axon::ir_nodes::{IRFlowNode, IRNamedArg, IRToolParam, IRToolSpec, IRUseToolStep};
use axon::secret_custody::InMemoryCustody;
use axon::tool_registry::ToolRegistry;
use std::sync::{Arc, Mutex};
const TENANT: &str = "kivi";
const TOKEN_ACME: &str = "acme-hubspot-token-111";
const TOKEN_GLOBEX: &str = "globex-hubspot-token-222";
const TOKEN_OTHER_CLASS: &str = "llm-provider-key-999";
fn partitioned_tool(url: &str) -> IRToolSpec {
IRToolSpec {
node_type: "tool_spec",
source_line: 1,
source_column: 1,
name: "CrmCrearContacto".to_string(),
provider: "http".to_string(),
max_results: None,
filter_expr: String::new(),
timeout: "10s".to_string(),
runtime: url.to_string(),
resource_ref: String::new(),
sandbox: None,
input_schema: Vec::new(),
output_schema: String::new(),
parameters: vec![
IRToolParam { name: "tenant_id".to_string(), type_name: "String".to_string(), optional: false },
IRToolParam { name: "nombre".to_string(), type_name: "String".to_string(), optional: false },
],
output_type: None,
requires: Vec::new(),
secret: "crm.hubspot".to_string(),
secret_partition: "tenant_id".to_string(),
effect_row: Vec::new(),
target: None,
risk: None,
argv: Vec::new(),
cache: String::new(),
scrape: None,
}
}
fn seeded_custody() -> Arc<InMemoryCustody> {
let c = InMemoryCustody::new();
c.seed(TENANT, "crm.hubspot.acme", TOKEN_ACME, None);
c.seed(TENANT, "crm.hubspot.globex", TOKEN_GLOBEX, None);
c.seed(TENANT, "llm.kimi", TOKEN_OTHER_CLASS, None);
Arc::new(c)
}
fn ctx_with(
custody: Arc<InMemoryCustody>,
tools: &[IRToolSpec],
) -> (
DispatchCtx,
tokio::sync::mpsc::UnboundedReceiver<axon::flow_execution_event::FlowExecutionEvent>,
) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mut ctx = DispatchCtx::new("F", "stub", "", CancellationFlag::new(), tx);
ctx.tenant_id = TENANT.to_string();
let mut registry = ToolRegistry::new();
registry.register_from_ir(tools);
ctx.tool_registry = Some(Arc::new(registry));
ctx = ctx.with_secret_custody(custody);
(ctx, rx)
}
async fn local_tool_server() -> (String, Arc<Mutex<Vec<String>>>) {
use axum::routing::post;
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let seen_for_handler = seen.clone();
let app = axum::Router::new().route(
"/",
post(move |body: String| {
let seen = seen_for_handler.clone();
async move {
seen.lock().unwrap().push(body.clone());
serde_json::json!({ "ok": true }).to_string()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{addr}/"), seen)
}
fn use_node(tenant_arg: Option<&str>) -> IRFlowNode {
let mut named = vec![IRNamedArg {
name: "nombre".to_string(),
value: "Ada".to_string(),
value_kind: "literal".to_string(),
}];
if let Some(t) = tenant_arg {
named.insert(
0,
IRNamedArg {
name: "tenant_id".to_string(),
value: t.to_string(),
value_kind: "literal".to_string(),
},
);
}
IRFlowNode::UseTool(IRUseToolStep {
node_type: "use_tool",
source_line: 1,
source_column: 1,
tool_name: "CrmCrearContacto".to_string(),
argument: String::new(),
named_args: named,
})
}
#[tokio::test]
async fn partition_resolves_the_right_sub_tenant_value() {
let (url, seen) = local_tool_server().await;
let (mut ctx, mut rx) = ctx_with(seeded_custody(), &[partitioned_tool(&url)]);
dispatch_node(&use_node(Some("acme")), &mut ctx)
.await
.expect("dispatch admits");
let bodies = seen.lock().unwrap().clone();
assert_eq!(bodies.len(), 1);
let body: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
assert_eq!(body["axon_secret"].as_str(), Some(TOKEN_ACME), "{}", bodies[0]);
assert_eq!(body["tenant_id"].as_str(), Some("acme"));
for (k, v) in &ctx.let_bindings {
assert!(!v.contains(TOKEN_ACME), "binding {k} leaked the value");
}
drop(ctx);
while let Ok(ev) = rx.try_recv() {
let json = serde_json::to_string(&ev).unwrap();
assert!(!json.contains(TOKEN_ACME), "value on the wire: {json}");
}
}
#[tokio::test]
async fn a_different_partition_value_selects_a_different_entry() {
let (url, seen) = local_tool_server().await;
let (mut ctx, _rx) = ctx_with(seeded_custody(), &[partitioned_tool(&url)]);
dispatch_node(&use_node(Some("globex")), &mut ctx)
.await
.expect("dispatch admits");
let bodies = seen.lock().unwrap().clone();
let body: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
assert_eq!(body["axon_secret"].as_str(), Some(TOKEN_GLOBEX), "{}", bodies[0]);
assert!(!bodies[0].contains(TOKEN_ACME), "{}", bodies[0]);
}
#[tokio::test]
async fn a_dotted_partition_value_cannot_escape_the_class() {
let (url, seen) = local_tool_server().await;
let (mut ctx, _rx) = ctx_with(seeded_custody(), &[partitioned_tool(&url)]);
let _ = dispatch_node(&use_node(Some("kimi.evil")), &mut ctx).await;
assert!(
seen.lock().unwrap().is_empty(),
"a dotted partition segment must refuse before any vendor call"
);
for (_k, v) in &ctx.let_bindings {
assert!(!v.contains(TOKEN_OTHER_CLASS), "other-class value must never surface");
}
}
#[tokio::test]
async fn a_missing_partition_argument_fails_closed() {
let (url, seen) = local_tool_server().await;
let (mut ctx, _rx) = ctx_with(seeded_custody(), &[partitioned_tool(&url)]);
let _ = dispatch_node(&use_node(None), &mut ctx).await;
assert!(
seen.lock().unwrap().is_empty(),
"an unbound partition must refuse before any vendor call"
);
}