use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn run_json(server_uri: &str, args: &[&str]) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.output()
.expect("run anvil")
}
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout is not valid JSON ({e}):\nSTDOUT:\n{s}\nSTDERR:\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_trigger_ok_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot/trigger"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"session": { "id": "sess_abc123", "status": "running" }
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"trigger",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["agent"]["session"]["id"], "sess_abc123");
assert!(v["agent"]["agent"].is_null(), "unexpected double nest: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_trigger_server_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot/trigger"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error":"boom"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"trigger",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "expected nonzero exit on 500");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"agents": [{"name":"deploy-bot","description":"deploys","trigger":"manual"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["agent", "list", "test-org/test-repo"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["agents"][0]["name"], "deploy-bot");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_list_empty_is_clean_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"agents": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["agent", "list", "test-org/test-repo"]);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert!(v["agents"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_view_echoes_wrapped_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"agent": {"name":"deploy-bot","description":"d","trigger":"manual","model":"opus"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"view",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["agent"]["name"], "deploy-bot");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_sessions_empty_is_clean_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/test-repo/agents/deploy-bot/sessions",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"sessions": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"sessions",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert!(v["sessions"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_session_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {"id":"sess_abc123","status":"running","agent_name":"deploy-bot"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"session",
"sess_abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["session"]["id"], "sess_abc123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_approve_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123/approve",
))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"approve",
"sess_abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["approved"]["session"], "sess_abc123");
}
(agent.rs:290, and identically :312 reject, :255 session):
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_approve_multibyte_id_stays_valid_json() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["agent", "approve", "€€€", "--repo", "test-org/test-repo"],
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("char boundary") && !stderr.contains("panic"),
"must not panic on a multibyte id; stderr:\n{stderr}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
serde_json::from_str::<Value>(&stdout).is_ok(),
"stdout must be valid JSON; got: {stdout}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_reject_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123/reject",
))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"reject",
"sess_abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["rejected"]["session"], "sess_abc123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_list_items_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ssh_keys": [{"id":"k1","name":"laptop","fingerprint":"SHA256:aaa"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "list"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert!(v["items"].is_array(), "expected items envelope: {v}");
assert_eq!(v["items"][0]["id"], "k1");
assert_eq!(v["items"][0]["fingerprint"], "SHA256:aaa");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_list_empty_is_items_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ssh_keys": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "list"]);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert_eq!(v["items"], json!([]));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_add_flat_body_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id":"k1","name":"laptop","fingerprint":"SHA256:bbb"
})))
.mount(&server)
.await;
let key_file = std::env::temp_dir().join("adv_ssh_key_flat.pub");
std::fs::write(&key_file, "ssh-ed25519 AAAAC3Nz test@host\n").unwrap();
let out = run_json(
&server.uri(),
&[
"ssh-key",
"add",
"--name",
"laptop",
"--key-file",
key_file.to_str().unwrap(),
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["ssh_key"]["fingerprint"], "SHA256:bbb");
assert!(
v["ssh_key"]["ssh_key"].is_null(),
"unexpected double nest: {v}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_add_wrapped_body_current_behavior() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"ssh_key": {"id":"k1","name":"laptop","fingerprint":"SHA256:ccc"}
})))
.mount(&server)
.await;
let key_file = std::env::temp_dir().join("adv_ssh_key_wrapped.pub");
std::fs::write(&key_file, "ssh-ed25519 AAAAC3Nz test@host\n").unwrap();
let out = run_json(
&server.uri(),
&[
"ssh-key",
"add",
"--name",
"laptop",
"--key-file",
key_file.to_str().unwrap(),
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["ssh_key"]["ssh_key"]["fingerprint"], "SHA256:ccc");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_remove_deleted_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/user/ssh-keys/k1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "remove", "k1"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "k1");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_remove_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/user/ssh-keys/nope"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error":"not found"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "remove", "nope"]);
assert!(!out.status.success(), "expected nonzero exit on 404");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_reports_available_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"version": "9999.0.0",
"platforms": ["linux_amd64"]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["update", "--check"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert!(v["current"].is_string(), "current missing: {v}");
assert_eq!(v["latest"], "9999.0.0");
assert_eq!(v["update_available"], true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_server_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(500).set_body_string("kaboom"))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["update", "--check"]);
assert!(!out.status.success(), "expected nonzero exit on 500");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}