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 {
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON: {e}\n--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_status_echoes_server_object_bare() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/deployments/status"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"environment": "prod",
"status": "running",
"ref": "main"
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["deploy", "status", "acme/web"]);
assert!(out.status.success(), "expected exit 0");
let v = stdout_json(&out);
assert!(
v.get("ok").is_none(),
"read must not add an ok wrapper: {v}"
);
assert_eq!(v["status"], "running");
assert_eq!(v["environment"], "prod");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_status_server_error_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/deployments/status"))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"boom"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["deploy", "status", "acme/web"]);
assert!(!out.status.success(), "5xx must exit nonzero");
let v = stdout_json(&out);
assert_eq!(
v["ok"],
json!(false),
"error envelope must be ok:false: {v}"
);
assert!(
v.get("error").is_some(),
"error envelope needs error key: {v}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_list_echoes_wrapped_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/environments/prod/deployments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"deployments": [
{"environment": "prod", "status": "running", "ref": "main"}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["deploy", "list", "acme/web", "--env", "prod"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["deployments"].is_array(),
"expected deployments array: {v}"
);
assert_eq!(v["deployments"][0]["status"], "running");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_list_empty_is_json_not_human_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/environments/prod/deployments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"deployments": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["deploy", "list", "acme/web", "--env", "prod"],
);
assert!(out.status.success());
let raw = String::from_utf8_lossy(&out.stdout);
assert!(
!raw.contains("(none)"),
"human (none) leaked to stdout: {raw}"
);
let v = stdout_json(&out);
assert_eq!(v["deployments"], json!([]));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_create_double_nests_wrapped_server_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/acme/web/environments/prod/deployments"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"deployment": {"id": "dep_1", "status": "queued", "ref": "main"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"deploy",
"create",
"--repo",
"acme/web",
"--env",
"prod",
"--deploy-ref",
"main",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
if v["deployment"]["deployment"].is_null() {
assert_eq!(v["deployment"]["status"], "queued");
} else {
assert_eq!(
v["deployment"]["deployment"]["status"], "queued",
"double-nest bug shape changed: {v}"
);
assert!(
v["deployment"]["status"].is_null(),
"status should NOT be directly reachable in the buggy shape: {v}"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_env_list_echoes_wrapped_body_and_empty() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/acme/web/environments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"environments": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["deploy", "env", "list", "acme/web"]);
assert!(out.status.success());
let raw = String::from_utf8_lossy(&out.stdout);
assert!(!raw.contains("(none)"), "human (none) leaked: {raw}");
let v = stdout_json(&out);
assert_eq!(v["environments"], json!([]));
assert!(v.get("ok").is_none(), "read must not add ok wrapper: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_env_create_double_nests_wrapped_server_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/acme/web/environments"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"environment": {"name": "staging", "id": "env_9"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"deploy", "env", "create", "--repo", "acme/web", "--name", "staging",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
if v["environment"]["environment"].is_null() {
assert_eq!(v["environment"]["name"], "staging");
} else {
assert_eq!(
v["environment"]["environment"]["name"], "staging",
"shape: {v}"
);
assert!(
v["environment"]["name"].is_null(),
"name should NOT be directly reachable in the buggy shape: {v}"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_create_wraps_flat_record() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "tok_1",
"name": "ci",
"token": "plaintext-secret-value",
"scopes": ["pull:acme/web"]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"registry", "token", "create", "--name", "ci", "--read", "--repo", "acme/web",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["token"]["token"], "plaintext-secret-value");
assert_eq!(v["token"]["id"], "tok_1");
assert!(v.get("token").is_some());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_list_uses_items_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tokens": [
{"id": "tok_1", "name": "ci", "scopes": ["pull:acme/web"]}
]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["items"].is_array(), "expected items envelope: {v}");
assert_eq!(v["items"][0]["id"], "tok_1");
assert!(v.get("tokens").is_none(), "must not echo server key: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_list_empty_is_items_empty_not_human() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"tokens": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "list"]);
assert!(out.status.success());
let raw = String::from_utf8_lossy(&out.stdout);
assert!(
!raw.contains("No registry tokens"),
"human info line leaked to stdout: {raw}"
);
let v = stdout_json(&out);
assert_eq!(v["items"], json!([]));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_list_missing_key_yields_empty_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/registry/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"unexpected": true})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "list"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["items"],
json!([]),
"missing key should degrade to []: {v}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_delete_emits_deleted_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/registry/tokens/tok_1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "delete", "tok_1"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v,
json!({"ok": true, "deleted": "tok_1"}),
"delete envelope: {v}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn registry_token_delete_404_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/registry/tokens/missing"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"not_found"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["registry", "token", "delete", "missing"]);
assert!(!out.status.success(), "404 must exit nonzero (not masked)");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false), "must be error envelope: {v}");
assert!(v.get("error").is_some());
assert!(
v.get("deleted").is_none(),
"deleted key present on failure: {v}"
);
}