ref:main
//! Adversarial `--json` contract tests for the deploy + registry commands.
//!
//! Commands under attack:
//! deploy status / list / create / env list / env create
//! registry token create / list / delete
//!
//! Each test boots a wiremock server, points the real `anvil` binary at it, and
//! asserts the exact JSON envelope the contract (src/output.rs) requires. Repo
//! is always passed explicitly so resolution never falls through to the dev's
//! git remote or config.
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),
)
})
}
// ---------------------------------------------------------------------------
// deploy status (GET /{org}/{repo}/deployments/status) -> Response::read
// ---------------------------------------------------------------------------
#[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);
// read echoes the bare server object: no ok wrapper, fields at top level.
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}"
);
}
// ---------------------------------------------------------------------------
// deploy list (GET /{org}/{repo}/environments/{env}/deployments) -> read
// ---------------------------------------------------------------------------
#[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);
// read echoes the whole server body verbatim (envelope preserved).
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!([]));
}
// ---------------------------------------------------------------------------
// deploy create (POST .../deployments) -> Response::ok("deployment", ...)
// BUG PATTERN 1: does NOT unwrap the server envelope, unlike every sibling
// (label/ci/release all do `resp.get(noun).cloned().unwrap_or(resp)`).
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deploy_create_double_nests_wrapped_server_body() {
let server = MockServer::start().await;
// Server wraps the record in a `deployment` envelope — the same shape the
// list endpoint uses (`deployments`/`data`) and that label/ci/release unwrap.
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));
// CONTRACT (desired): v["deployment"]["deployment"] is null, and the real
// field is reachable at v["deployment"]["status"].
//
// ACTUAL (bug): the command wraps the whole server body — which is itself
// `{"deployment":{...}}` — so we get {"ok":true,"deployment":{"deployment":{...}}}.
// The status a script wants is buried at v["deployment"]["deployment"]["status"].
// We document the CURRENT buggy behavior so the suite stays green.
if v["deployment"]["deployment"].is_null() {
// Contract upheld (migration was fixed).
assert_eq!(v["deployment"]["status"], "queued");
} else {
// BUG confirmed: double-nested envelope.
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}"
);
}
}
// ---------------------------------------------------------------------------
// deploy env list (GET /{org}/{repo}/environments) -> read
// ---------------------------------------------------------------------------
#[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}");
}
// ---------------------------------------------------------------------------
// deploy env create (POST /{org}/{repo}/environments) -> ok("environment", ..)
// BUG PATTERN 1: no envelope unwrap.
// ---------------------------------------------------------------------------
#[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() {
// Contract upheld.
assert_eq!(v["environment"]["name"], "staging");
} else {
// BUG confirmed: {"ok":true,"environment":{"environment":{...}}}.
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}"
);
}
}
// ---------------------------------------------------------------------------
// registry token create (POST /registry/tokens) -> ok("token", resp)
// Server returns a FLAT record with the plaintext at resp["token"], so the
// whole record wrapped under "token" is the intended shape (no double-nest).
// ---------------------------------------------------------------------------
#[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));
// The plaintext is reachable and the record is not lost.
assert_eq!(v["token"]["token"], "plaintext-secret-value");
assert_eq!(v["token"]["id"], "tok_1");
// Ensure the plaintext isn't leaked at the top level (only under the record).
assert!(v.get("token").is_some());
}
// ---------------------------------------------------------------------------
// registry token list (GET /registry/tokens) -> Response::items (client list)
// ---------------------------------------------------------------------------
#[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);
// Client-built list contract: {"items":[...]}, NOT the raw server body.
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;
// Server omits the `tokens` key entirely.
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);
// Graceful: empty items, not a panic or null garbage.
assert_eq!(
v["items"],
json!([]),
"missing key should degrade to []: {v}"
);
}
// ---------------------------------------------------------------------------
// registry token delete (DELETE /registry/tokens/{id}) -> Response::deleted
// ---------------------------------------------------------------------------
#[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());
// Must NOT falsely report a successful delete.
assert!(
v.get("deleted").is_none(),
"deleted key present on failure: {v}"
);
}