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 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let cfg = std::env::temp_dir().join(format!("anvil-adv-{}-{nanos}.json", std::process::id()));
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.env("ANVIL_CONFIG", cfg)
.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 was not a single JSON value ({e}). stdout=<<<{s}>>> stderr=<<<{}>>>",
String::from_utf8_lossy(&out.stderr)
)
})
}
#[tokio::test]
async fn auth_status_logged_in_is_bare_read() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["auth", "status"]);
assert!(out.status.success(), "status should exit 0");
let v = stdout_json(&out);
assert_eq!(v["logged_in"], json!(true));
assert_eq!(v["server"], json!(server.uri()));
assert!(
v.get("ok").is_none(),
"status must not be an ok-envelope: {v}"
);
assert!(
v.get("default_repo").is_some(),
"default_repo key present (may be null)"
);
}
#[tokio::test]
async fn auth_logout_is_ok_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["auth", "logout"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert!(v["logout"].is_object(), "expected logout object: {v}");
assert!(
v["logout"]["removed"].is_boolean(),
"removed should be bool: {v}"
);
assert!(v["logout"]["logout"].is_null(), "no double-nest: {v}");
}
#[tokio::test]
async fn auth_rotate_yes_is_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"token": "anvil_newtok",
"revoked": {"token_prefix": "old12345"}
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["auth", "rotate", "--yes"]);
assert!(
out.status.success(),
"rotate --yes should succeed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["rotated"]["token"], json!("anvil_newtok"));
assert_eq!(v["rotated"]["revoked"]["token_prefix"], json!("old12345"));
assert!(v["rotated"]["rotated"].is_null(), "no double-nest: {v}");
}
#[tokio::test]
async fn auth_rotate_without_yes_under_json_is_error_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["auth", "rotate"]);
assert!(
!out.status.success(),
"must exit nonzero when refusing to rotate"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
assert!(v["error"].is_string(), "error must be a string: {v}");
}
#[tokio::test]
async fn auth_rotate_server_omits_token_is_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"revoked": {}})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["auth", "rotate", "--yes"]);
assert!(!out.status.success(), "missing token must be an error exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
#[tokio::test]
async fn auth_rotate_server_5xx_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/tokens/rotate"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "boom"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["auth", "rotate", "--yes"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["ok"],
json!(false),
"server error must surface as error envelope: {v}"
);
}
#[tokio::test]
async fn repo_list_wraps_in_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"repositories": [
{"slug": "a", "visibility": "private", "org": {"slug": "acme"}},
{"slug": "b", "visibility": "public", "org": {"slug": "acme"}}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v.is_object(), "expected object envelope, got: {v}");
assert!(v["items"].is_array(), "expected items array: {v}");
assert_eq!(v["items"].as_array().unwrap().len(), 2);
assert_eq!(v["items"][0]["slug"], json!("a"));
}
#[tokio::test]
async fn repo_list_empty_is_empty_items_not_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"repositories": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["items"],
json!([]),
"empty list must be [], no (none) leak: {v}"
);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
!s.contains("(none)"),
"human (none) leaked into JSON stdout: {s}"
);
}
#[tokio::test]
async fn repo_list_bare_array_body_still_wraps_in_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"slug": "solo", "org": {"slug": "acme"}}
])))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["items"].is_array(),
"bare array body must still wrap in items: {v}"
);
assert_eq!(v["items"][0]["slug"], json!("solo"));
}
#[tokio::test]
async fn repo_list_org_filter_applies_to_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"repositories": [
{"slug": "a", "org": {"slug": "acme"}},
{"slug": "b", "org": {"slug": "widgets"}}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list", "--org", "widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
let items = v["items"].as_array().unwrap();
assert_eq!(items.len(), 1, "org filter must apply to JSON too: {v}");
assert_eq!(items[0]["slug"], json!("b"));
}
#[tokio::test]
async fn repo_list_server_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/repos"))
.respond_with(ResponseTemplate::new(503).set_body_json(json!({"error": "down"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "list"]);
assert!(!out.status.success(), "5xx must be nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
#[tokio::test]
async fn repo_view_is_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"slug": "widget",
"visibility": "private",
"default_branch": "main",
"org": {"slug": "acme"}
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "view", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v.get("ok").is_none(),
"view is a read, not ok-envelope: {v}"
);
assert_eq!(v["slug"], json!("widget"));
assert_eq!(v["default_branch"], json!("main"));
}
#[tokio::test]
async fn repo_view_404_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/ghost"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not_found"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["repo", "view", "acme/ghost"]);
assert!(!out.status.success(), "404 must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
#[tokio::test]
async fn repo_create_flat_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/acme/repos"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"slug": "newrepo",
"visibility": "private"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["repo", "create", "--name", "newrepo", "--org", "acme"],
);
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["repo"]["slug"], json!("newrepo"));
assert!(v["repo"]["repo"].is_null(), "no double-nest of repo: {v}");
}
#[tokio::test]
async fn repo_create_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/acme/repos"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({"error": "name taken"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["repo", "create", "--name", "dup", "--org", "acme"],
);
assert!(!out.status.success(), "422 must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
#[tokio::test]
async fn repo_set_default_is_ok_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["repo", "set-default", "acme/widget"]);
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["default_repo"], json!("acme/widget"), "shape: {v}");
}
#[tokio::test]
async fn repo_set_default_invalid_format_is_error_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["repo", "set-default", "notaslug"]);
assert!(
!out.status.success(),
"invalid repo format must exit nonzero"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
#[tokio::test]
async fn commit_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/commits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"commits": [
{"oid": "abcdef1234", "message": "first\nbody", "author": {"name": "cole"}, "time": "2026-01-01T00:00:00Z"}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["commit", "list", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["commits"].is_array(),
"expected echoed commits array: {v}"
);
assert_eq!(v["commits"][0]["oid"], json!("abcdef1234"));
assert!(v.get("ok").is_none(), "read must not be ok-enveloped: {v}");
}
#[tokio::test]
async fn commit_list_empty_no_none_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/commits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"commits": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["commit", "list", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["commits"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(
!s.contains("(none)"),
"human (none) leaked to JSON stdout: {s}"
);
}
#[tokio::test]
async fn commit_list_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/commits"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "boom"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["commit", "list", "acme/widget"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}
#[tokio::test]
async fn branch_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/branches"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"branches": [
{"name": "main", "sha": "deadbeef00"},
{"name": "dev", "target": "cafef00d11"}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["branch", "list", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["branches"].is_array(),
"expected echoed branches array: {v}"
);
assert_eq!(v["branches"][0]["name"], json!("main"));
assert!(v.get("ok").is_none(), "read must not be ok-enveloped: {v}");
}
#[tokio::test]
async fn branch_list_empty_no_none_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/branches"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"branches": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["branch", "list", "acme/widget"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["branches"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(
!s.contains("(none)"),
"human (none) leaked to JSON stdout: {s}"
);
}
#[tokio::test]
async fn branch_list_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/widget/branches"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "no repo"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["branch", "list", "acme/widget"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "expected error envelope: {v}");
}