use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
static DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
fn assay_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_assay"))
}
fn unique_dir(prefix: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let seq = DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("{prefix}-{nonce}-{seq}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_script(dir: &Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, content).unwrap();
path
}
fn resume_state_path(state_dir: &Path, token: &str) -> PathBuf {
state_dir.join("resume").join(format!("{token}.json"))
}
fn stdout_json(output: &std::process::Output) -> Value {
serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"stdout not JSON ({e}): {}",
String::from_utf8_lossy(&output.stdout)
)
})
}
fn scrub(cmd: &mut Command) {
for key in [
"ASSAY_APPROVAL",
"ASSAY_APPROVED_INDICES",
"ASSAY_APPROVED_OPS",
"ASSAY_DENIED_INDEX",
"ASSAY_READONLY",
"ASSAY_MODE",
"ASSAY_APPROVAL_RESULT",
] {
cmd.env_remove(key);
}
}
fn tool_cmd(script: &Path, flags: &[&str], state_dir: &Path) -> Command {
let mut cmd = Command::new(assay_binary());
cmd.arg("run").arg("--mode").arg("tool");
for flag in flags {
cmd.arg(flag);
}
cmd.arg(script);
scrub(&mut cmd);
cmd.env("ASSAY_STATE_DIR", state_dir);
cmd
}
fn resume_cmd(token: &str, approve: &str, state_dir: &Path) -> Command {
let mut cmd = Command::new(assay_binary());
cmd.arg("resume")
.arg("--token")
.arg(token)
.arg("--approve")
.arg(approve);
scrub(&mut cmd);
cmd.env("ASSAY_STATE_DIR", state_dir);
cmd
}
async fn run_blocking(mut cmd: Command) -> std::process::Output {
tokio::task::spawn_blocking(move || cmd.output().unwrap())
.await
.unwrap()
}
fn post_script(dir: &Path, name: &str, url: &str) -> PathBuf {
write_script(
dir,
name,
&format!("local r = http.post(\"{url}/submit\", \"{{}}\")\nreturn {{ status = r.status }}"),
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mutation_halts_with_descriptor_and_no_request() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/submit"))
.respond_with(ResponseTemplate::new(201))
.mount(&server)
.await;
let dir = unique_dir("assay-approval-halt");
let state_dir = dir.join("state");
let script = post_script(&dir, "post.lua", &server.uri());
let out = run_blocking(tool_cmd(&script, &["--approval-mode"], &state_dir)).await;
let json = stdout_json(&out);
assert_eq!(json["ok"], Value::Bool(true));
assert_eq!(json["status"], "needs_approval");
assert_eq!(json["output"], Value::Null);
let ra = &json["requiresApproval"];
assert_eq!(ra["op"], "http.post");
assert_eq!(ra["index"], 0);
assert!(
ra["summary"].as_str().unwrap().contains("/submit"),
"summary: {}",
ra["summary"]
);
let token = ra["resumeToken"].as_str().unwrap();
assert_eq!(token.len(), 32);
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
assert!(resume_state_path(&state_dir, token).exists());
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_cycle_admits_one_op_per_approval() {
let server_one = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(201))
.mount(&server_one)
.await;
let server_two = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(202))
.mount(&server_two)
.await;
let dir = unique_dir("assay-approval-cycle");
let state_dir = dir.join("state");
let script = write_script(
&dir,
"two_ops.lua",
&format!(
"local a = http.post(\"{one}/x\", \"{{}}\")\n\
local b = http.post(\"{two}/x\", \"{{}}\")\n\
return {{ one = a.status, two = b.status }}",
one = server_one.uri(),
two = server_two.uri(),
),
);
let first = run_blocking(tool_cmd(&script, &["--approval-mode"], &state_dir)).await;
let first_json = stdout_json(&first);
assert_eq!(first_json["status"], "needs_approval");
assert_eq!(first_json["requiresApproval"]["index"], 0);
let token_a = first_json["requiresApproval"]["resumeToken"]
.as_str()
.unwrap()
.to_string();
assert!(server_one.received_requests().await.unwrap().is_empty());
assert!(server_two.received_requests().await.unwrap().is_empty());
let second = run_blocking(resume_cmd(&token_a, "yes", &state_dir)).await;
let second_json = stdout_json(&second);
assert_eq!(second_json["status"], "needs_approval");
assert_eq!(second_json["requiresApproval"]["index"], 1);
let token_b = second_json["requiresApproval"]["resumeToken"]
.as_str()
.unwrap()
.to_string();
assert!(!server_one.received_requests().await.unwrap().is_empty());
assert!(
server_two.received_requests().await.unwrap().is_empty(),
"op 1 must not run until approved"
);
let third = run_blocking(resume_cmd(&token_b, "yes", &state_dir)).await;
let third_json = stdout_json(&third);
assert_eq!(third_json["status"], "ok");
assert_eq!(third_json["output"]["one"], 201);
assert_eq!(third_json["output"]["two"], 202);
assert!(!server_two.received_requests().await.unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deny_fails_operation_and_skips_request() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/submit"))
.respond_with(ResponseTemplate::new(201))
.mount(&server)
.await;
let dir = unique_dir("assay-approval-deny");
let state_dir = dir.join("state");
let script = post_script(&dir, "post.lua", &server.uri());
let first = run_blocking(tool_cmd(&script, &["--approval-mode"], &state_dir)).await;
let token = stdout_json(&first)["requiresApproval"]["resumeToken"]
.as_str()
.unwrap()
.to_string();
let denied = run_blocking(resume_cmd(&token, "no", &state_dir)).await;
let json = stdout_json(&denied);
assert_eq!(json["ok"], Value::Bool(false));
assert_eq!(json["status"], "error");
assert!(
json["error"]
.as_str()
.unwrap()
.contains("approval: http.post denied"),
"error: {}",
json["error"]
);
assert!(server.received_requests().await.unwrap().is_empty());
}
#[test]
fn read_only_op_runs_freely_in_approval_mode() {
let dir = unique_dir("assay-approval-read");
let state_dir = dir.join("state");
let data = dir.join("data.txt");
std::fs::write(&data, "readable payload").unwrap();
let script = write_script(
&dir,
"read.lua",
&format!("return {{ content = fs.read(\"{}\") }}", data.display()),
);
let out = tool_cmd(&script, &["--approval-mode"], &state_dir)
.output()
.unwrap();
let json = stdout_json(&out);
assert_eq!(json["status"], "ok");
assert_eq!(json["output"]["content"], "readable payload");
}
#[test]
fn flag_and_env_activate_approval() {
let dir = unique_dir("assay-approval-activate");
let target = dir.join("out.txt");
let script = write_script(
&dir,
"write.lua",
&format!(
"fs.write(\"{}\", \"data\")\nreturn {{ done = true }}",
target.display()
),
);
let flag_out = tool_cmd(&script, &["--approval-mode"], &dir.join("state-flag"))
.output()
.unwrap();
let flag_json = stdout_json(&flag_out);
assert_eq!(flag_json["status"], "needs_approval");
assert_eq!(flag_json["requiresApproval"]["op"], "fs.write");
assert!(!target.exists(), "fs.write must not run before approval");
let mut env_cmd = tool_cmd(&script, &[], &dir.join("state-env"));
env_cmd.env("ASSAY_APPROVAL", "1");
let env_json = stdout_json(&env_cmd.output().unwrap());
assert_eq!(env_json["status"], "needs_approval");
assert_eq!(env_json["requiresApproval"]["op"], "fs.write");
}
#[test]
fn approval_mode_wins_over_readonly() {
let dir = unique_dir("assay-approval-precedence");
let state_dir = dir.join("state");
let target = dir.join("out.txt");
let script = write_script(
&dir,
"write.lua",
&format!(
"fs.write(\"{}\", \"data\")\nreturn {{ done = true }}",
target.display()
),
);
let out = tool_cmd(&script, &["--readonly", "--approval-mode"], &state_dir)
.output()
.unwrap();
let json = stdout_json(&out);
assert_eq!(
json["status"], "needs_approval",
"approval must win: {json}"
);
assert_eq!(json["requiresApproval"]["op"], "fs.write");
assert!(json.get("error").is_none());
assert!(json.get("readonly").is_none(), "envelope: {json}");
}
#[test]
fn env_marker_does_not_self_trip_in_approval_mode() {
let dir = unique_dir("assay-approval-marker");
let state_dir = dir.join("state");
let script = write_script(
&dir,
"marker.lua",
"return { mode = env.get(\"ASSAY_MODE\") }",
);
let out = tool_cmd(&script, &["--approval-mode"], &state_dir)
.output()
.unwrap();
let json = stdout_json(&out);
assert_eq!(
json["status"], "ok",
"marker must not trip the gate: {json}"
);
assert_eq!(json["output"]["mode"], "tool");
}
#[test]
fn modules_reports_approval_mode() {
let mut cmd = Command::new(assay_binary());
cmd.args(["modules", "--approval-mode"]);
scrub(&mut cmd);
let out = cmd.output().unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("approval mode active"), "stdout: {stdout}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_grant_cannot_be_spent_on_a_different_op() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/flag"))
.respond_with(ResponseTemplate::new(200).set_body_string("first"))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/flag"))
.respond_with(ResponseTemplate::new(200).set_body_string("second"))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/submit"))
.respond_with(ResponseTemplate::new(201))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/submit"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let dir = unique_dir("assay-approval-binding");
let state_dir = dir.join("state");
let script = write_script(
&dir,
"shifty.lua",
&format!(
r#"local flag = http.get("{uri}/flag")
if flag.body == "first" then
local r = http.post("{uri}/submit", "{{}}")
return {{ status = r.status }}
else
local r = http.put("{uri}/submit", "{{}}")
return {{ status = r.status }}
end"#,
uri = server.uri()
),
);
let out = run_blocking(tool_cmd(&script, &["--approval-mode"], &state_dir)).await;
let json = stdout_json(&out);
assert_eq!(json["status"], "needs_approval", "first run: {json}");
assert_eq!(json["requiresApproval"]["op"], "http.post");
let token = json["requiresApproval"]["resumeToken"]
.as_str()
.unwrap()
.to_string();
let out = run_blocking(resume_cmd(&token, "yes", &state_dir)).await;
let json = stdout_json(&out);
assert_eq!(json["status"], "error", "shifted op must not run: {json}");
let err = json["error"].as_str().unwrap();
assert!(err.contains("changed since approval"), "error: {err}");
assert!(
err.contains("http.post") && err.contains("http.put"),
"error names both ops: {err}"
);
let hits = server.received_requests().await.unwrap();
assert!(
hits.iter().all(|r| !r.url.path().ends_with("/submit")),
"no mutation may reach the server"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn approved_index_without_op_binding_is_refused() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/submit"))
.respond_with(ResponseTemplate::new(201))
.mount(&server)
.await;
let dir = unique_dir("assay-approval-unbound");
let state_dir = dir.join("state");
let script = post_script(&dir, "post.lua", &server.uri());
let mut cmd = tool_cmd(&script, &["--approval-mode"], &state_dir);
cmd.env("ASSAY_APPROVED_INDICES", "0");
let out = run_blocking(cmd).await;
let json = stdout_json(&out);
assert_eq!(json["status"], "error", "unbound grant must refuse: {json}");
let err = json["error"].as_str().unwrap();
assert!(
err.contains("no operation binding"),
"error explains the refusal: {err}"
);
assert!(server.received_requests().await.unwrap().is_empty());
}