use super::*;
use crate::ast::decompose::{DecomposeSpec, DecomposeStrategy};
use crate::ast::output::{OutputFormat, OutputPolicy, SchemaRef};
use crate::ast::{ExecParams, FetchParams, InferParams, InvokeParams};
use crate::event::EventKind;
use crate::store::{RunContext, TaskResult};
use serde_json::json;
use std::time::Duration;
#[test]
fn test_executor_new_default() {
let executor = TaskExecutor::new("claude", None, None, EventLog::new());
assert_eq!(executor.default_provider.as_ref(), "claude");
assert!(executor.default_model.is_none());
}
#[test]
fn test_executor_new_with_model() {
let executor = TaskExecutor::new("openai", Some("gpt-4"), None, EventLog::new());
assert_eq!(executor.default_provider.as_ref(), "openai");
assert_eq!(executor.default_model.as_deref(), Some("gpt-4"));
}
#[test]
fn test_executor_new_with_mcp_configs() {
let mut mcp_configs = rustc_hash::FxHashMap::default();
mcp_configs.insert(
"novanet".to_string(),
McpConfigInline {
command: "cargo run".to_string(),
args: vec![
"--manifest-path".to_string(),
"path/to/Cargo.toml".to_string(),
],
env: rustc_hash::FxHashMap::default(),
cwd: None,
},
);
let executor = TaskExecutor::new("mock", None, Some(mcp_configs), EventLog::new());
assert_eq!(executor.default_provider.as_ref(), "mock");
}
#[test]
fn test_executor_is_clone() {
let exec = TaskExecutor::new("mock", None, None, EventLog::new());
let _cloned = exec.clone();
}
#[tokio::test]
async fn test_execute_exec_simple_command() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo hello".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_echo");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "hello");
}
#[tokio::test]
async fn test_execute_exec_with_template_binding() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("name", json!("world"));
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo {{with.name}}".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_template");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "world");
}
#[tokio::test]
async fn test_execute_exec_command_failure() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "false".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_fail");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err());
match result.unwrap_err() {
NikaError::Execution(msg) => {
assert!(
msg.contains("failed") || msg.contains("exit code"),
"Expected failure message, got: {msg}"
);
}
err => panic!("Expected Execution error, got: {err:?}"),
}
}
#[tokio::test]
async fn test_execute_exec_emits_template_resolved() {
let event_log = EventLog::new();
let executor = TaskExecutor::new("mock", None, None, event_log.clone());
let mut bindings = ResolvedBindings::new();
bindings.set("greeting", json!("Hello"));
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo {{with.greeting}}".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_event");
executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
let events = event_log.filter_task("test_event");
assert!(!events.is_empty());
let template_events: Vec<_> = events
.iter()
.filter(|e| matches!(e.kind, EventKind::TemplateResolved { .. }))
.collect();
assert_eq!(template_events.len(), 1);
if let EventKind::TemplateResolved { result, .. } = &template_events[0].kind {
assert_eq!(result, "echo Hello");
}
}
#[tokio::test]
async fn test_execute_fetch_invalid_url() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Fetch {
fetch: FetchParams {
url: "http://invalid.example.invalid".to_string(),
method: "GET".to_string(),
headers: rustc_hash::FxHashMap::default(),
body: None,
json: None,
timeout: None,
retry: None,
follow_redirects: None,
response: None,
extract: None,
selector: None,
},
};
let task_id: Arc<str> = Arc::from("test_fetch_fail");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_execute_fetch_with_template_url() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("endpoint", json!("httpbin.org/get"));
let datastore = RunContext::new();
let action = TaskAction::Fetch {
fetch: FetchParams {
url: "https://{{with.endpoint}}".to_string(),
method: "GET".to_string(),
headers: rustc_hash::FxHashMap::default(),
body: None,
json: None,
timeout: None,
retry: None,
follow_redirects: None,
response: None,
extract: None,
selector: None,
},
};
let task_id: Arc<str> = Arc::from("test_fetch_template");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
let events = EventLog::new();
let executor2 = TaskExecutor::new("mock", None, None, events.clone());
let result2 = executor2
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert_eq!(result.is_ok(), result2.is_ok());
}
#[tokio::test]
async fn test_execute_invoke_tool_call() {
let event_log = EventLog::new();
let executor = TaskExecutor::new("mock", None, None, event_log.clone());
executor.inject_mock_mcp_client("novanet");
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: Some("novanet".to_string()),
tool: Some("novanet_context".to_string()),
params: Some(json!({"entity": "qr-code", "locale": "fr-FR"})),
resource: None,
timeout: None,
},
};
let task_id: Arc<str> = Arc::from("test_invoke");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_ok(), "Invoke should succeed: {:?}", result.err());
let output = result.unwrap();
assert!(
output.contains("entity"),
"Output should contain entity: {output}"
);
}
#[tokio::test]
async fn test_execute_invoke_resource_read() {
let event_log = EventLog::new();
let executor = TaskExecutor::new("mock", None, None, event_log);
executor.inject_mock_mcp_client("novanet");
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: Some("novanet".to_string()),
tool: None,
params: None,
resource: Some("neo4j://entity/qr-code".to_string()),
timeout: None,
},
};
let task_id: Arc<str> = Arc::from("test_resource");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(
result.is_ok(),
"Resource read should succeed: {:?}",
result.err()
);
let output = result.unwrap();
assert!(
output.contains("qr-code"),
"Output should contain resource id: {output}"
);
}
#[tokio::test]
async fn test_execute_invoke_emits_mcp_events() {
let event_log = EventLog::new();
let executor = TaskExecutor::new("mock", None, None, event_log.clone());
executor.inject_mock_mcp_client("novanet");
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: Some("novanet".to_string()),
tool: Some("novanet_describe".to_string()),
params: None,
resource: None,
timeout: None,
},
};
let task_id: Arc<str> = Arc::from("test_mcp_events");
executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
let events = event_log.filter_task("test_mcp_events");
assert!(!events.is_empty(), "Should emit events");
let invoke_events: Vec<_> = events
.iter()
.filter(|e| matches!(e.kind, EventKind::McpInvoke { .. }))
.collect();
assert_eq!(invoke_events.len(), 1, "Should emit McpInvoke event");
let response_events: Vec<_> = events
.iter()
.filter(|e| matches!(e.kind, EventKind::McpResponse { .. }))
.collect();
assert_eq!(response_events.len(), 1, "Should emit McpResponse event");
}
#[tokio::test]
async fn test_execute_invoke_tool_with_template_params() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
executor.inject_mock_mcp_client("novanet");
let mut bindings = ResolvedBindings::new();
bindings.set("entity_key", json!("qr-code"));
bindings.set("locale_val", json!("en-US"));
let datastore = RunContext::new();
let action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: Some("novanet".to_string()),
tool: Some("novanet_context".to_string()),
params: Some(json!({
"entity": "{{with.entity_key}}",
"locale": "{{with.locale_val}}"
})),
resource: None,
timeout: None,
},
};
let task_id: Arc<str> = Arc::from("test_invoke_template");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(
result.is_ok(),
"Invoke with template params should succeed: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_execute_invoke_validation_error_both_tool_and_resource() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: Some("novanet".to_string()),
tool: Some("test_tool".to_string()),
params: None,
resource: Some("test://resource".to_string()),
timeout: None,
},
};
let task_id: Arc<str> = Arc::from("test_invalid");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Should fail with validation error");
match result.unwrap_err() {
NikaError::ValidationError { reason } => {
assert!(reason.contains("mutually exclusive"));
}
err => panic!("Expected ValidationError, got: {err:?}"),
}
}
#[tokio::test]
async fn test_execute_invoke_validation_error_neither_tool_nor_resource() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: Some("novanet".to_string()),
tool: None,
params: None,
resource: None,
timeout: None,
},
};
let task_id: Arc<str> = Arc::from("test_neither");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Should fail with validation error");
match result.unwrap_err() {
NikaError::ValidationError { reason } => {
assert!(reason.contains("either") || reason.contains("must be specified"));
}
err => panic!("Expected ValidationError, got: {err:?}"),
}
}
#[tokio::test]
async fn test_execute_invoke_mcp_not_configured() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: Some("unconfigured_server".to_string()),
tool: Some("some_tool".to_string()),
params: None,
resource: None,
timeout: None,
},
};
let task_id: Arc<str> = Arc::from("test_unconfigured");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Should fail with McpNotConfigured");
match result.unwrap_err() {
NikaError::McpNotConfigured { name } => {
assert_eq!(name, "unconfigured_server");
}
err => panic!("Expected McpNotConfigured, got: {err:?}"),
}
}
#[tokio::test]
async fn test_builtin_invoke_stages_media_ref() {
let event_log = EventLog::new();
let executor = TaskExecutor::new("mock", None, None, event_log);
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let tmp_dir = tempfile::tempdir().unwrap();
let img_path = tmp_dir.path().join("test.png");
std::fs::write(&img_path, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).unwrap();
let action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: None,
tool: Some("nika:import".to_string()),
params: Some(json!({"path": img_path.to_string_lossy()})),
resource: None,
timeout: None,
},
};
let task_id: Arc<str> = Arc::from("test_builtin_media");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed["hash"].as_str().unwrap().starts_with("blake3:"));
let media_refs = datastore.take_media(&task_id);
assert_eq!(
media_refs.len(),
1,
"builtin media tool must stage exactly 1 MediaRef"
);
let mr = &media_refs[0];
assert!(
mr.hash.starts_with("blake3:"),
"MediaRef hash must be blake3-prefixed"
);
assert_eq!(mr.created_by, "test_builtin_media");
assert!(mr.size_bytes > 0);
}
#[tokio::test]
async fn test_binding_resolution_single_template() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("key", json!("value123"));
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo {{with.key}}".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_binding");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "value123");
}
#[tokio::test]
async fn test_binding_resolution_multiple_templates() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("first", json!("hello"));
bindings.set("second", json!("world"));
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo {{with.first}} {{with.second}}".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_multi");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "hello world");
}
#[tokio::test]
async fn test_binding_resolution_no_templates() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo static".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_static");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "static");
}
#[tokio::test]
async fn test_binding_resolution_json_value() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!({"id": 42, "name": "test"}));
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo {{with.data}}".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_json");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert!(result.contains("id"));
assert!(result.contains("42"));
}
#[tokio::test]
async fn test_binding_resolution_datastore_lookup() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("task_output", json!({"result": "success"}));
let datastore = RunContext::new();
let task_id_prev: Arc<str> = Arc::from("prev_task");
datastore.insert(
task_id_prev.clone(),
TaskResult::success_str("from_previous_task", Duration::from_millis(100)),
);
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo {{with.task_output}}".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_store");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert!(result.contains("success"));
}
#[tokio::test]
async fn test_expand_decompose_static() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("items", json!(["item1", "item2", "item3"]));
let datastore = RunContext::new();
let spec = DecomposeSpec {
strategy: DecomposeStrategy::Static,
traverse: "HAS_CHILD".to_string(),
source: "{{with.items}}".to_string(),
max_items: None,
max_depth: None,
mcp_server: None,
};
let result = executor
.expand_decompose(&spec, &bindings, &datastore)
.await
.unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].as_str().unwrap(), "item1");
assert_eq!(result[1].as_str().unwrap(), "item2");
assert_eq!(result[2].as_str().unwrap(), "item3");
}
#[tokio::test]
async fn test_expand_decompose_static_with_max_items() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("items", json!(["a", "b", "c", "d", "e"]));
let datastore = RunContext::new();
let spec = DecomposeSpec {
strategy: DecomposeStrategy::Static,
traverse: "HAS_CHILD".to_string(),
source: "{{with.items}}".to_string(),
max_items: Some(2),
max_depth: None,
mcp_server: None,
};
let result = executor
.expand_decompose(&spec, &bindings, &datastore)
.await
.unwrap();
assert_eq!(result.len(), 2);
}
#[tokio::test]
async fn test_expand_decompose_static_wrong_type() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("notarray", json!({"key": "value"}));
let datastore = RunContext::new();
let spec = DecomposeSpec {
strategy: DecomposeStrategy::Static,
traverse: "HAS_CHILD".to_string(),
source: "{{with.notarray}}".to_string(),
max_items: None,
max_depth: None,
mcp_server: None,
};
let result = executor
.expand_decompose(&spec, &bindings, &datastore)
.await;
assert!(result.is_err(), "Should fail with type mismatch");
}
#[tokio::test]
async fn test_extract_decompose_key_from_string() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let value = json!("entity:qr-code");
let key = executor.extract_decompose_key(&value).unwrap();
assert_eq!(key, "entity:qr-code");
}
#[tokio::test]
async fn test_extract_decompose_key_from_object() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let value = json!({"key": "entity:test", "name": "Test Entity"});
let key = executor.extract_decompose_key(&value).unwrap();
assert_eq!(key, "entity:test");
}
#[tokio::test]
async fn test_extract_decompose_key_invalid() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let value = json!(123);
let result = executor.extract_decompose_key(&value);
assert!(result.is_err());
}
#[tokio::test]
async fn test_extract_decompose_nodes_from_nodes_field() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result_json = json!({
"nodes": [
{"key": "node1"},
{"key": "node2"}
]
});
let nodes = executor.extract_decompose_nodes(result_json).unwrap();
assert_eq!(nodes.len(), 2);
}
#[tokio::test]
async fn test_extract_decompose_nodes_from_items_field() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result_json = json!({
"items": ["item1", "item2", "item3"]
});
let nodes = executor.extract_decompose_nodes(result_json).unwrap();
assert_eq!(nodes.len(), 3);
}
#[tokio::test]
async fn test_extract_decompose_nodes_from_results_field() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result_json = json!({
"results": ["result1", "result2"]
});
let nodes = executor.extract_decompose_nodes(result_json).unwrap();
assert_eq!(nodes.len(), 2);
}
#[tokio::test]
async fn test_extract_decompose_nodes_from_array() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result_json = json!(["direct1", "direct2"]);
let nodes = executor.extract_decompose_nodes(result_json).unwrap();
assert_eq!(nodes.len(), 2);
}
#[tokio::test]
async fn test_extract_decompose_nodes_empty_nodes() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result_json = json!({"nodes": []});
let nodes = executor.extract_decompose_nodes(result_json).unwrap();
assert_eq!(nodes.len(), 0);
}
#[tokio::test]
async fn test_error_handling_exec_timeout() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "sleep 100".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_timeout");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Should timeout");
match result.unwrap_err() {
NikaError::Execution(msg) => {
assert!(msg.contains("timed out") || msg.contains("timeout"));
}
err => panic!("Expected Execution error with timeout, got: {err:?}"),
}
}
#[tokio::test]
async fn test_action_type_helper() {
let infer_action = TaskAction::Infer {
infer: crate::ast::InferParams {
prompt: "test".to_string(),
..Default::default()
},
};
assert_eq!(action_type(&infer_action), "infer");
let exec_action = TaskAction::Exec {
exec: ExecParams {
command: "echo test".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
assert_eq!(action_type(&exec_action), "exec");
let fetch_action = TaskAction::Fetch {
fetch: FetchParams {
url: "http://example.com".to_string(),
method: "GET".to_string(),
headers: rustc_hash::FxHashMap::default(),
body: None,
json: None,
timeout: None,
retry: None,
follow_redirects: None,
response: None,
extract: None,
selector: None,
},
};
assert_eq!(action_type(&fetch_action), "fetch");
let invoke_action = TaskAction::Invoke {
invoke: InvokeParams {
mcp: Some("novanet".to_string()),
tool: Some("test".to_string()),
params: None,
resource: None,
timeout: None,
},
};
assert_eq!(action_type(&invoke_action), "invoke");
let agent_action = TaskAction::Agent {
agent: crate::ast::AgentParams {
prompt: "test".to_string(),
provider: None,
model: None,
system: None,
mcp: vec![],
tools: vec![],
max_turns: None,
stop_sequences: vec![],
scope: None,
token_budget: None,
extended_thinking: None,
thinking_budget: None,
depth_limit: None,
tool_choice: None,
temperature: None,
max_tokens: None,
skills: None,
completion: None,
guardrails: vec![],
limits: None,
},
};
assert_eq!(action_type(&agent_action), "agent");
}
#[tokio::test]
async fn test_execute_exec_blocked_by_policy() {
let policy_config = PolicyConfig {
allow_exec: true,
blocked_commands: vec!["dangerous_tool".to_string(), "custom_block".to_string()],
..Default::default()
};
let executor =
TaskExecutor::with_policy("mock", None, None, EventLog::new(), Some(policy_config));
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "dangerous_tool --flag".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_policy_exec");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Should be blocked by policy");
match result.unwrap_err() {
NikaError::PolicyViolation { reason } => {
assert!(
reason.contains("dangerous_tool"),
"Reason should mention blocked pattern"
);
}
err => panic!("Expected PolicyViolation, got: {err:?}"),
}
}
#[tokio::test]
async fn test_execute_exec_allowed_by_policy() {
let policy_config = PolicyConfig {
allow_exec: true,
blocked_commands: vec!["sudo".to_string()],
..Default::default()
};
let executor =
TaskExecutor::with_policy("mock", None, None, EventLog::new(), Some(policy_config));
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo hello".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_policy_exec_allowed");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_ok(), "Should be allowed: {:?}", result.err());
assert_eq!(result.unwrap(), "hello");
}
#[tokio::test]
async fn test_execute_exec_disabled_by_policy() {
let policy_config = PolicyConfig {
allow_exec: false,
..Default::default()
};
let executor =
TaskExecutor::with_policy("mock", None, None, EventLog::new(), Some(policy_config));
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo safe".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_policy_exec_disabled");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Should be blocked when exec is disabled");
match result.unwrap_err() {
NikaError::PolicyViolation { reason } => {
assert!(
reason.contains("disabled"),
"Reason should mention disabled"
);
}
err => panic!("Expected PolicyViolation, got: {err:?}"),
}
}
#[tokio::test]
async fn test_execute_fetch_blocked_by_policy() {
let policy_config = PolicyConfig {
allow_network: true,
blocked_hosts: vec!["evil.com".to_string()],
..Default::default()
};
let executor =
TaskExecutor::with_policy("mock", None, None, EventLog::new(), Some(policy_config));
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Fetch {
fetch: FetchParams {
url: "https://evil.com/api".to_string(),
method: "GET".to_string(),
headers: rustc_hash::FxHashMap::default(),
body: None,
json: None,
timeout: None,
retry: None,
follow_redirects: None,
response: None,
extract: None,
selector: None,
},
};
let task_id: Arc<str> = Arc::from("test_policy_fetch");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Should be blocked by policy");
match result.unwrap_err() {
NikaError::PolicyViolation { reason } => {
assert!(reason.contains("blocked"), "Reason should mention blocked");
}
err => panic!("Expected PolicyViolation, got: {err:?}"),
}
}
#[tokio::test]
async fn test_execute_fetch_disabled_by_policy() {
let policy_config = PolicyConfig {
allow_network: false,
..Default::default()
};
let executor =
TaskExecutor::with_policy("mock", None, None, EventLog::new(), Some(policy_config));
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Fetch {
fetch: FetchParams {
url: "https://api.example.com".to_string(),
method: "GET".to_string(),
headers: rustc_hash::FxHashMap::default(),
body: None,
json: None,
timeout: None,
retry: None,
follow_redirects: None,
response: None,
extract: None,
selector: None,
},
};
let task_id: Arc<str> = Arc::from("test_policy_fetch_disabled");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(
result.is_err(),
"Should be blocked when network is disabled"
);
match result.unwrap_err() {
NikaError::PolicyViolation { reason } => {
assert!(
reason.contains("disabled"),
"Reason should mention disabled"
);
}
err => panic!("Expected PolicyViolation, got: {err:?}"),
}
}
#[tokio::test]
async fn test_executor_with_policy_config() {
let policy_config = PolicyConfig {
allow_exec: true,
allow_network: false,
max_token_spend: Some(1000),
..Default::default()
};
let executor =
TaskExecutor::with_policy("mock", None, None, EventLog::new(), Some(policy_config));
assert_eq!(executor.default_provider.as_ref(), "mock");
}
#[test]
fn test_shlex_split_simple_command() {
let parts = shlex::split("echo hello world").unwrap();
assert_eq!(parts, vec!["echo", "hello", "world"]);
}
#[test]
fn test_shlex_split_quoted_args() {
let parts = shlex::split(r#"echo "hello world""#).unwrap();
assert_eq!(parts, vec!["echo", "hello world"]);
}
#[test]
fn test_shlex_split_single_quoted() {
let parts = shlex::split("echo 'hello world'").unwrap();
assert_eq!(parts, vec!["echo", "hello world"]);
}
#[test]
fn test_shlex_split_escaped_characters() {
let parts = shlex::split(r#"echo hello\ world"#).unwrap();
assert_eq!(parts, vec!["echo", "hello world"]);
}
#[tokio::test]
async fn test_run_exec_shell_free_mode_default() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test_shell_free");
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let params = ExecParams {
command: "echo hello; echo world".to_string(),
shell: None, timeout: None,
cwd: None,
env: None,
};
let result = executor
.run_exec(&task_id, ¶ms, &bindings, &datastore)
.await;
assert!(result.is_ok() || result.is_err());
if let Ok(output) = result {
assert!(output.contains("hello;") || output.contains("hello"));
}
}
#[tokio::test]
async fn test_run_exec_shell_true_mode_interprets_metacharacters() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test_shell_true");
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let params = ExecParams {
command: "echo hello && echo world".to_string(),
shell: Some(true),
timeout: None,
cwd: None,
env: None,
};
let result = executor
.run_exec(&task_id, ¶ms, &bindings, &datastore)
.await
.unwrap();
assert!(result.contains("hello"));
assert!(result.contains("world"));
}
#[tokio::test]
async fn test_run_exec_shell_free_prevents_injection() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test_injection");
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let params = ExecParams {
command: "echo 'hello; echo injected'".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
};
let result = executor
.run_exec(&task_id, ¶ms, &bindings, &datastore)
.await
.unwrap();
assert!(result.contains("hello; echo injected") || result.contains("hello;"));
}
#[tokio::test]
async fn test_run_exec_security_validation_blocks_dangerous_commands() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test_blocked");
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let params = ExecParams {
command: "rm -rf /".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
};
let result = executor
.run_exec(&task_id, ¶ms, &bindings, &datastore)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("NIKA-053") || err.to_string().contains("blocked"));
}
#[test]
fn test_build_json_schema_instruction_none_policy() {
let result = TaskExecutor::build_json_schema_instruction(None);
assert!(result.is_none());
}
#[test]
fn test_build_json_schema_instruction_text_format() {
let policy = OutputPolicy {
format: OutputFormat::Text,
schema: Some(SchemaRef::Inline(json!({"type": "object"}))),
max_retries: None,
source_structured_spec: None,
};
let result = TaskExecutor::build_json_schema_instruction(Some(&policy));
assert!(
result.is_none(),
"Text format should not produce schema instruction"
);
}
#[test]
fn test_build_json_schema_instruction_json_no_schema() {
let policy = OutputPolicy {
format: OutputFormat::Json,
schema: None,
max_retries: None,
source_structured_spec: None,
};
let result = TaskExecutor::build_json_schema_instruction(Some(&policy));
assert!(
result.is_none(),
"JSON format without schema should return None"
);
}
#[test]
fn test_build_json_schema_instruction_json_inline_schema() {
let schema = json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"required": ["name", "age"]
});
let policy = OutputPolicy {
format: OutputFormat::Json,
schema: Some(SchemaRef::Inline(schema.clone())),
max_retries: None,
source_structured_spec: None,
};
let result = TaskExecutor::build_json_schema_instruction(Some(&policy));
assert!(result.is_some());
let instruction = result.unwrap();
assert!(instruction.contains("CRITICAL OUTPUT REQUIREMENT"));
assert!(instruction.contains("\"name\""));
assert!(instruction.contains("\"age\""));
assert!(instruction.contains("conforms to this schema"));
}
#[test]
fn test_build_json_schema_instruction_json_file_schema() {
let policy = OutputPolicy {
format: OutputFormat::Json,
schema: Some(SchemaRef::File("schemas/user.json".to_string())),
max_retries: None,
source_structured_spec: None,
};
let result = TaskExecutor::build_json_schema_instruction(Some(&policy));
assert!(result.is_some());
let instruction = result.unwrap();
assert!(instruction.contains("CRITICAL OUTPUT REQUIREMENT"));
assert!(instruction.contains("valid JSON"));
assert!(!instruction.contains("conforms to this schema"));
}
#[test]
fn test_build_json_schema_instruction_yaml_format() {
let policy = OutputPolicy {
format: OutputFormat::Yaml,
schema: Some(SchemaRef::Inline(json!({"type": "object"}))),
max_retries: None,
source_structured_spec: None,
};
let result = TaskExecutor::build_json_schema_instruction(Some(&policy));
assert!(
result.is_none(),
"YAML format should not produce schema instruction"
);
}
#[test]
fn test_build_json_schema_instruction_markdown_format() {
let policy = OutputPolicy {
format: OutputFormat::Markdown,
schema: Some(SchemaRef::Inline(json!({"type": "object"}))),
max_retries: None,
source_structured_spec: None,
};
let result = TaskExecutor::build_json_schema_instruction(Some(&policy));
assert!(
result.is_none(),
"Markdown format should not produce schema instruction"
);
}
#[test]
fn test_get_rig_provider_unknown_provider() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result = executor.get_rig_provider("nonexistent_provider");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("nonexistent_provider"),
"Error should mention the unknown provider name"
);
}
#[test]
fn test_get_rig_provider_empty_name() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result = executor.get_rig_provider("");
assert!(result.is_err(), "Empty provider name should fail");
}
#[tokio::test]
async fn test_run_infer_mock_basic() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test-infer-mock");
let bindings = ResolvedBindings::default();
let datastore = RunContext::default();
let infer = InferParams {
prompt: "Generate a test response".to_string(),
..Default::default()
};
let result = executor
.run_infer(&task_id, &infer, &bindings, &datastore, None)
.await;
assert!(
result.is_ok(),
"Mock infer should succeed: {:?}",
result.err()
);
let response = result.unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&response).expect("Mock response should be valid JSON");
assert_eq!(parsed["mock"], true);
assert_eq!(parsed["task_id"], "test-infer-mock");
assert_eq!(parsed["status"], "success");
assert!(parsed["items"].is_array());
}
#[tokio::test]
async fn test_run_infer_mock_emits_provider_responded() {
let event_log = EventLog::new();
let executor = TaskExecutor::new("mock", None, None, event_log.clone());
let task_id: Arc<str> = Arc::from("test-infer-events");
let bindings = ResolvedBindings::default();
let datastore = RunContext::default();
let infer = InferParams {
prompt: "Hello mock".to_string(),
..Default::default()
};
executor
.run_infer(&task_id, &infer, &bindings, &datastore, None)
.await
.expect("Mock infer should succeed");
let events = event_log.events();
let has_provider_responded = events.iter().any(|e| {
matches!(
&e.kind,
EventKind::ProviderResponded { task_id, .. } if task_id.as_ref() == "test-infer-events"
)
});
assert!(
has_provider_responded,
"Should emit ProviderResponded event"
);
}
#[tokio::test]
async fn test_run_infer_mock_with_json_schema_injection() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test-infer-schema");
let bindings = ResolvedBindings::default();
let datastore = RunContext::default();
let infer = InferParams {
prompt: "Generate user data".to_string(),
..Default::default()
};
let policy = OutputPolicy {
format: OutputFormat::Json,
schema: Some(SchemaRef::Inline(json!({
"type": "object",
"properties": { "name": { "type": "string" } }
}))),
max_retries: None,
source_structured_spec: None,
};
let result = executor
.run_infer(&task_id, &infer, &bindings, &datastore, Some(&policy))
.await;
assert!(result.is_ok(), "Mock infer with schema should succeed");
}
#[tokio::test]
async fn test_run_infer_mock_with_task_level_provider() {
let executor = TaskExecutor::new("claude", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test-infer-override");
let bindings = ResolvedBindings::default();
let datastore = RunContext::default();
let infer = InferParams {
prompt: "Test task-level provider override".to_string(),
provider: Some("mock".to_string()),
..Default::default()
};
let result = executor
.run_infer(&task_id, &infer, &bindings, &datastore, None)
.await;
assert!(
result.is_ok(),
"Task-level mock provider override should succeed: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_run_infer_empty_prompt() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test-infer-empty");
let bindings = ResolvedBindings::default();
let datastore = RunContext::default();
let infer = InferParams {
prompt: " ".to_string(),
..Default::default()
};
let result = executor
.run_infer(&task_id, &infer, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Empty prompt should fail validation");
let err = result.unwrap_err().to_string();
assert!(
err.contains("empty") || err.contains("Empty") || err.contains("prompt"),
"Error should mention empty prompt: {}",
err
);
}
#[tokio::test]
async fn test_run_exec_with_env_vars() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let mut env = rustc_hash::FxHashMap::default();
env.insert("MY_VAR".to_string(), "hello_from_env".to_string());
let action = TaskAction::Exec {
exec: ExecParams {
command: "sh -c 'echo $MY_VAR'".to_string(),
shell: Some(true),
timeout: None,
cwd: None,
env: Some(env),
},
};
let task_id: Arc<str> = Arc::from("test_env");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "hello_from_env");
}
#[tokio::test]
async fn test_run_exec_with_env_template_resolution() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("env_val", json!("resolved_value"));
let datastore = RunContext::new();
let mut env = rustc_hash::FxHashMap::default();
env.insert("DYNAMIC".to_string(), "{{with.env_val}}".to_string());
let action = TaskAction::Exec {
exec: ExecParams {
command: "sh -c 'echo $DYNAMIC'".to_string(),
shell: Some(true),
timeout: None,
cwd: None,
env: Some(env),
},
};
let task_id: Arc<str> = Arc::from("test_env_tpl");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "resolved_value");
}
#[tokio::test]
async fn test_run_exec_with_multiple_env_vars() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let mut env = rustc_hash::FxHashMap::default();
env.insert("A".to_string(), "first".to_string());
env.insert("B".to_string(), "second".to_string());
let action = TaskAction::Exec {
exec: ExecParams {
command: "sh -c 'echo ${A}_${B}'".to_string(),
shell: Some(true),
timeout: None,
cwd: None,
env: Some(env),
},
};
let task_id: Arc<str> = Arc::from("test_multi_env");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "first_second");
}
#[tokio::test]
async fn test_run_exec_with_custom_timeout() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo fast".to_string(),
shell: None,
timeout: Some(10), cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("test_timeout_ok");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "fast");
}
#[tokio::test]
async fn test_run_infer_mock_with_template_binding() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test-infer-tpl");
let mut bindings = ResolvedBindings::new();
bindings.set("topic", json!("quantum computing"));
let datastore = RunContext::default();
let infer = InferParams {
prompt: "Explain {{with.topic}} in simple terms".to_string(),
..Default::default()
};
let result = executor
.run_infer(&task_id, &infer, &bindings, &datastore, None)
.await;
assert!(
result.is_ok(),
"Infer with template should succeed: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_run_infer_mock_missing_binding_fails() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let task_id: Arc<str> = Arc::from("test-infer-missing");
let bindings = ResolvedBindings::new(); let datastore = RunContext::default();
let infer = InferParams {
prompt: "Process {{with.nonexistent}}".to_string(),
..Default::default()
};
let result = executor
.run_infer(&task_id, &infer, &bindings, &datastore, None)
.await;
assert!(result.is_err(), "Missing binding should fail");
}
#[tokio::test]
async fn test_run_infer_mock_with_model_override() {
let executor = TaskExecutor::new("mock", Some("default-model"), None, EventLog::new());
let task_id: Arc<str> = Arc::from("test-infer-model");
let bindings = ResolvedBindings::default();
let datastore = RunContext::default();
let infer = InferParams {
prompt: "Generate something".to_string(),
model: Some("custom-model".to_string()),
..Default::default()
};
let result = executor
.run_infer(&task_id, &infer, &bindings, &datastore, None)
.await;
assert!(
result.is_ok(),
"Model override should succeed with mock: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_resolve_decompose_source_literal() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let result = executor.resolve_decompose_source("some-key", &bindings, &datastore);
assert!(result.is_ok());
assert_eq!(result.unwrap(), json!("some-key"));
}
#[tokio::test]
async fn test_resolve_decompose_source_template() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("entity", json!("qr-code"));
let datastore = RunContext::new();
let result = executor.resolve_decompose_source("{{with.entity}}", &bindings, &datastore);
assert!(result.is_ok());
assert_eq!(result.unwrap(), json!("qr-code"));
}
#[tokio::test]
async fn test_resolve_decompose_source_dollar_binding() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("my_key", json!("entity-key"));
let datastore = RunContext::new();
let result = executor.resolve_decompose_source("$my_key", &bindings, &datastore);
assert!(result.is_ok());
assert_eq!(result.unwrap(), json!("entity-key"));
}
#[tokio::test]
async fn test_resolve_decompose_source_missing_binding_fails() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let result = executor.resolve_decompose_source("$nonexistent", &bindings, &datastore);
assert!(result.is_err(), "Missing binding should fail");
}
#[tokio::test]
async fn test_resolve_decompose_source_dollar_path_from_datastore() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
datastore.insert(
Arc::from("prev_task"),
TaskResult::success(json!({"key": "nested-key"}), Duration::from_millis(1)),
);
let result = executor.resolve_decompose_source("$prev_task.key", &bindings, &datastore);
assert!(result.is_ok());
assert_eq!(result.unwrap(), json!("nested-key"));
}
#[test]
fn test_json_type_name_all_types() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
assert_eq!(executor.json_type_name(&json!(null)), "null");
assert_eq!(executor.json_type_name(&json!(true)), "boolean");
assert_eq!(executor.json_type_name(&json!(42)), "number");
assert_eq!(executor.json_type_name(&json!("text")), "string");
assert_eq!(executor.json_type_name(&json!([])), "array");
assert_eq!(executor.json_type_name(&json!({})), "object");
}
#[tokio::test]
async fn test_extract_decompose_key_from_object_with_extra_fields() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let value = json!({"key": "my-entity", "name": "My Entity", "type": "Entity"});
let key = executor.extract_decompose_key(&value).unwrap();
assert_eq!(key, "my-entity");
}
#[tokio::test]
async fn test_extract_decompose_key_from_number_fails() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result = executor.extract_decompose_key(&json!(42));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("number"),
"Should mention the actual type: {}",
err
);
}
#[tokio::test]
async fn test_extract_decompose_key_from_null_fails() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result = executor.extract_decompose_key(&json!(null));
assert!(result.is_err());
}
#[tokio::test]
async fn test_extract_decompose_nodes_from_non_object_non_array_fails() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result = executor.extract_decompose_nodes(json!("just a string"));
assert!(result.is_err());
}
#[tokio::test]
async fn test_extract_decompose_nodes_from_object_without_known_fields_fails() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let result = executor.extract_decompose_nodes(json!({"data": [1, 2, 3]}));
assert!(result.is_err());
}
#[tokio::test]
async fn audit_exec_timeout_fires_promptly() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "sleep 60".to_string(),
shell: None,
timeout: Some(1),
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("audit_timeout");
let start = std::time::Instant::now();
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
let elapsed = start.elapsed();
assert!(result.is_err(), "Should timeout");
assert!(
elapsed < Duration::from_secs(5),
"Timeout should fire in ~1s, took {:?}",
elapsed
);
match result.unwrap_err() {
NikaError::Execution(msg) => {
assert!(msg.contains("timed out"), "Expected timeout, got: {}", msg);
}
err => panic!("Expected Execution error, got: {:?}", err),
}
}
#[tokio::test]
async fn audit_exec_json_object_binding_breaks_shlex() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!({"key": "value"}));
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "echo {{with.data}}".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("audit_json_shlex");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
match result {
Ok(output) => {
assert!(
output.contains("key"),
"JSON content should appear: {}",
output
);
}
Err(err) => {
let msg = err.to_string();
assert!(
msg.contains("parse command") || msg.contains("unbalanced"),
"Expected shlex parse error: {}",
msg
);
}
}
}
#[tokio::test]
async fn audit_exec_stderr_in_error_message() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "sh -c 'echo AUDIT_ERROR >&2 && exit 1'".to_string(),
shell: Some(true),
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("audit_stderr");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("AUDIT_ERROR"),
"Error should contain stderr, got: {}",
err_msg
);
}
#[tokio::test]
async fn audit_exec_output_trimmed() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "printf 'hello\\n\\n\\n'".to_string(),
shell: Some(true),
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("audit_trim");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert_eq!(result, "hello", "Output should be trimmed: '{}'", result);
}
#[tokio::test]
async fn audit_blocklist_extra_spaces_bypass() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "rm -rf /".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("audit_spaces");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
if result.is_ok() {
panic!(
"GAP CONFIRMED: 'rm -rf /' bypasses blocklist! \
contains() needs exact whitespace. Fix: normalize \
whitespace before blocklist check."
);
}
}
#[tokio::test]
async fn audit_blocklist_tab_bypass() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "rm\t-rf\t/".to_string(),
shell: None,
timeout: None,
cwd: None,
env: None,
},
};
let task_id: Arc<str> = Arc::from("audit_tabs");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await;
if result.is_ok() {
panic!(
"GAP CONFIRMED: 'rm\\t-rf\\t/' bypasses blocklist! \
Tabs pass control char check but blocklist patterns use \
spaces. Fix: normalize whitespace before blocklist check."
);
}
}
#[test]
fn audit_blocklist_flag_reorder_bypass() {
let result = crate::runtime::security::check_blocklist("rm -f -r /");
if result.is_ok() {
} else {
}
}
#[test]
fn audit_blocklist_newline_still_caught() {
let cmd = "echo safe\nrm -rf /";
let result = crate::runtime::security::check_blocklist(cmd);
assert!(
result.is_err(),
"Newline-separated 'rm -rf /' should be caught by contains()"
);
}
#[tokio::test]
async fn audit_exec_cwd_is_wired() {
let executor = TaskExecutor::new("mock", None, None, EventLog::new());
let bindings = ResolvedBindings::new();
let datastore = RunContext::new();
let action = TaskAction::Exec {
exec: ExecParams {
command: "pwd".to_string(),
shell: Some(true),
timeout: None,
cwd: Some("/tmp".to_string()),
env: None,
},
};
let task_id: Arc<str> = Arc::from("audit_cwd");
let result = executor
.execute(&task_id, &action, &bindings, &datastore, None)
.await
.unwrap();
assert!(
result == "/tmp" || result == "/private/tmp",
"GAP: cwd not wired. Expected /tmp or /private/tmp, got: '{}'",
result
);
}