use mlua_swarm::core::config::EngineCfg;
use mlua_swarm::core::engine::Engine;
use mlua_swarm::core::state::{CapTokenRecord, TaskSpec, TaskState};
use mlua_swarm::{CapToken, Role, StepId};
use mlua_swarm_schema::{VerdictChannel, VerdictContract};
use std::collections::HashMap;
use std::time::Duration;
async fn seed_task_with_handle(engine: &Engine, task_id: &StepId, agent: &str) -> String {
let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
let task_id = task_id.clone();
let agent = agent.to_string();
let handle_clone = handle.clone();
engine
.with_state("test.seed_task_with_handle", move |s| {
let task = TaskState::new(
task_id.clone(),
TaskSpec {
agent: agent.clone(),
initial_directive: serde_json::json!("x"),
step_ctx: None,
check_policy: None,
},
);
s.tasks.insert(task_id.clone(), task);
let token = CapToken {
agent_id: agent,
role: Role::Worker,
scopes: vec!["*".to_string()],
issued_at: 0,
expire_at: u64::MAX,
max_uses: None,
nonce: format!("test-nonce-{task_id}"),
sig_hex: String::new(),
};
let fp = token.fingerprint();
s.tokens.insert(
fp.clone(),
CapTokenRecord {
token,
uses_left: None,
revoked: false,
task_id: Some(task_id),
},
);
s.worker_handles.insert(handle_clone, fp);
})
.await
.expect("seed_task_with_handle");
handle
}
async fn seed_task_with_token(engine: &Engine, task_id: &StepId, agent: &str) -> CapToken {
let task_id_for_state = task_id.clone();
let agent_for_state = agent.to_string();
engine
.with_state("test.seed_task_with_token", move |s| {
let task = TaskState::new(
task_id_for_state.clone(),
TaskSpec {
agent: agent_for_state,
initial_directive: serde_json::json!("x"),
step_ctx: None,
check_policy: None,
},
);
s.tasks.insert(task_id_for_state.clone(), task);
})
.await
.expect("seed_task_with_token");
let token = engine.signer().session(
agent.to_string(),
Role::Worker,
vec!["*".to_string()],
Duration::from_secs(600),
);
let fp = token.fingerprint();
let record = CapTokenRecord::from_worker_token(token.clone(), task_id.clone());
engine
.with_state("test.register_token", move |s| {
s.tokens.insert(fp, record);
})
.await
.expect("register token");
token
}
async fn spawn_server(engine: Engine) -> String {
let router = mlua_swarm_server::build_router(engine);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral port");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
format!("http://{addr}")
}
fn body_contract(values: &[&str]) -> VerdictContract {
VerdictContract {
channel: VerdictChannel::Body,
values: values.iter().map(|v| v.to_string()).collect(),
}
}
fn part_contract(values: &[&str]) -> VerdictContract {
VerdictContract {
channel: VerdictChannel::Part,
values: values.iter().map(|v| v.to_string()).collect(),
}
}
#[tokio::test]
async fn worker_submit_rejects_body_outside_contract_values() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
body_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let handle = seed_task_with_handle(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/submit"))
.header("Authorization", format!("Bearer {handle}"))
.body("UNKNOWN")
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = resp.json().await.expect("json body");
let error = body["error"].as_str().expect("error string");
assert!(
error.contains("PASS") && error.contains("BLOCKED"),
"error should echo declared values: {error}"
);
}
#[tokio::test]
async fn worker_submit_accepts_body_inside_contract_values() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
body_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let handle = seed_task_with_handle(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine.clone()).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/submit"))
.header("Authorization", format!("Bearer {handle}"))
.body("PASS")
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let last_result = engine
.with_state("test.read_last_result", {
let task_id = task_id.clone();
move |s| s.tasks.get(&task_id).and_then(|t| t.last_result.clone())
})
.await
.expect("read last_result");
assert_eq!(last_result, Some(serde_json::json!("PASS")));
}
#[tokio::test]
async fn worker_artifact_verdict_part_rejects_value_outside_contract() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
part_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let handle = seed_task_with_handle(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/artifact?name=verdict"))
.header("Authorization", format!("Bearer {handle}"))
.body("UNKNOWN")
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn worker_artifact_non_verdict_part_skips_the_gate() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
part_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let handle = seed_task_with_handle(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/artifact?name=notes"))
.header("Authorization", format!("Bearer {handle}"))
.body("anything at all")
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn worker_submit_without_a_declared_contract_is_unaffected() {
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::new();
let handle = seed_task_with_handle(&engine, &task_id, "undeclared-agent").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/submit"))
.header("Authorization", format!("Bearer {handle}"))
.body("anything at all, no contract to violate")
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn worker_submit_rejects_missing_verdict_part_when_channel_is_part() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
part_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let handle = seed_task_with_handle(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/submit"))
.header("Authorization", format!("Bearer {handle}"))
.body("a full report, never staged as a verdict part")
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = resp.json().await.expect("json body");
let error = body["error"].as_str().expect("error string");
assert!(
error.contains("verdict"),
"error should name the missing part: {error}"
);
}
#[tokio::test]
async fn worker_submit_ok_false_bypasses_the_gate_regardless_of_value() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
body_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let handle = seed_task_with_handle(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/submit?ok=false"))
.header("Authorization", format!("Bearer {handle}"))
.body("UNKNOWN")
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn worker_result_rejects_missing_verdict_part_when_channel_is_part() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
part_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let token = seed_task_with_token(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/result"))
.header("Authorization", format!("Bearer {}", token.encode()))
.json(&serde_json::json!({
"task_id": task_id.as_str(),
"value": "a full report, never staged as a verdict part",
"ok": true,
}))
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = resp.json().await.expect("json body");
let error = body["error"].as_str().expect("error string");
assert!(
error.contains("verdict"),
"error should name the missing part: {error}"
);
}
#[tokio::test]
async fn worker_result_rejects_body_value_outside_contract() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
body_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let token = seed_task_with_token(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/result"))
.header("Authorization", format!("Bearer {}", token.encode()))
.json(&serde_json::json!({
"task_id": task_id.as_str(),
"value": "UNKNOWN",
"ok": true,
}))
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = resp.json().await.expect("json body");
let error = body["error"].as_str().expect("error string");
assert!(
error.contains("PASS") && error.contains("BLOCKED"),
"error should echo declared values: {error}"
);
}
#[tokio::test]
async fn worker_result_ok_false_bypasses_the_gate_regardless_of_value() {
let engine = Engine::new(EngineCfg::default());
engine.register_verdict_contracts(HashMap::from([(
"gate".to_string(),
body_contract(&["PASS", "BLOCKED"]),
)]));
let task_id = StepId::new();
let token = seed_task_with_token(&engine, &task_id, "gate").await;
let base_url = spawn_server(engine).await;
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/v1/worker/result"))
.header("Authorization", format!("Bearer {}", token.encode()))
.json(&serde_json::json!({
"task_id": task_id.as_str(),
"value": "UNKNOWN",
"ok": false,
}))
.send()
.await
.expect("request");
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
}