ref:main
//! Adversarial `--json` contract tests for `anvil release …`.
//!
//! Drives the real binary against a mock Anvil, feeding edge-case server
//! bodies to hunt the migration bug patterns: double-nesting, wrong container,
//! discarded/missing keys, error-path masking, empty lists leaking human text.
//!
//! Every command file path is /api/v1-prefixed by the client, so mounts match
//! the full `/api/v1/<org>/<name>/releases…` path. Repo is always passed
//! explicitly so `resolve_repo` never touches the developer's git remote.
use serde_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 {
// Point ANVIL_CONFIG at a nonexistent scratch file so no on-disk config
// (e.g. a default_repo) can perturb behavior; env creds below take over.
let cfg = std::env::temp_dir().join(format!("anvil-adv-release-cfg-{}.json", unique_suffix()));
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 unique_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
}
/// Parse stdout as exactly one JSON value (the whole contract).
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(s.trim())
.unwrap_or_else(|e| panic!("stdout was not a single JSON value: {e}\n--- stdout ---\n{s}"))
}
fn exit_code(out: &Output) -> i32 {
out.status.code().unwrap_or(-1)
}
const REPO: &str = "acme/widgets";
const BASE: &str = "/api/v1/acme/widgets/releases";
// ---------------------------------------------------------------------------
// create — double-nest probe (Pattern 1) + error path (Pattern 4)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn create_wrapped_body_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "First", "draft": true}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"release", "create", "--repo", REPO, "--tag", "v1.0.0", "--title", "First",
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
// The server wrapper must be unwrapped: no {"release":{"release":…}}.
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert_eq!(v["release"]["draft"], Value::Bool(true));
}
#[tokio::test]
async fn create_flat_body_wraps_under_release() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"tag_name": "v1.0.0", "title": "First"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "create", "--repo", REPO, "--tag", "v1.0.0"],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
}
#[tokio::test]
async fn create_server_error_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(
ResponseTemplate::new(422)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "tag already exists"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "create", "--repo", REPO, "--tag", "v1.0.0"],
);
let v = stdout_json(&out);
assert_eq!(
v["ok"],
Value::Bool(false),
"expected failure envelope: {v}"
);
assert!(v.get("error").is_some(), "missing error field: {v}");
assert_ne!(exit_code(&out), 0, "error path must exit nonzero");
}
// ---------------------------------------------------------------------------
// view — echo shape (Pattern 1) + 200-with-odd-types bug (Pattern 3) + 404
// ---------------------------------------------------------------------------
#[tokio::test]
async fn view_wrapped_body_echoes_bare_release() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "T"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
// read echoes the bare payload: top-level fields reachable, no "release" wrapper.
assert_eq!(v["tag_name"], "v1.0.0");
assert!(
v["release"].is_null(),
"should have unwrapped the envelope: {v}"
);
}
/// FIXED (Pattern 3): `view` now echoes the raw 200 body verbatim; the strict
/// `Release` struct only drives the human table, so a valid 200 read whose
/// `author` field is a bare username string (rather than an object) still
/// succeeds and its payload is echoed unchanged.
/// src/commands/release.rs:308
#[tokio::test]
async fn view_200_with_string_author_echoes_body_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tag_name": "v1.0.0",
"title": "T",
"author": "cole" // server sends a username string, not {username:…}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
// A 200 read echoes the server payload verbatim, even with a string author.
assert_eq!(exit_code(&out), 0, "200 read must succeed: {v}");
assert_eq!(v["author"], "cole", "{v}");
assert_eq!(v["tag_name"], "v1.0.0", "{v}");
}
#[tokio::test]
async fn view_404_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v9.9.9")))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "not found"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v9.9.9", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert_ne!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// list — echo array (Pattern 2) + empty (Pattern 6) + null-collection (Pattern 3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_wrapped_array_echoes_bare_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": [{"tag_name": "v1.0.0"}, {"tag_name": "v0.9.0"}]
})))
.mount(&server)
.await;
// `list` takes REPO as a positional, not --repo.
let out = run_json(&server.uri(), &["release", "list", REPO]);
let v = stdout_json(&out);
assert!(v.is_array(), "list should echo a bare array: {v}");
assert_eq!(v.as_array().unwrap().len(), 2);
assert_eq!(v[0]["tag_name"], "v1.0.0");
}
#[tokio::test]
async fn list_empty_is_empty_array_no_human_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": []
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["release", "list", REPO]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("(none)"),
"human '(none)' leaked: {stdout}"
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!([]), "empty list should be []: {v}");
}
/// FIXED (Pattern 3/6): when the server sends `{"releases": null}`, `list` now
/// normalizes the present-but-null collection to an empty JSON array instead of
/// leaking a bare `null`, so a consumer doing `jq '.[]'` keeps working.
/// src/commands/release.rs:254-258, 290
#[tokio::test]
async fn list_null_collection_emits_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": null
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["release", "list", REPO]);
let v = stdout_json(&out);
// A present-but-null collection normalizes to [].
assert!(
v.is_array() && v.as_array().unwrap().is_empty(),
"expected []: {v}"
);
assert_eq!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// update — double-nest (Pattern 1) + no-op path
// ---------------------------------------------------------------------------
#[tokio::test]
async fn update_wrapped_body_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "New"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"release", "update", "v1.0.0", "--title", "New", "--repo", REPO,
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
assert_eq!(v["release"]["title"], "New");
}
#[tokio::test]
async fn update_no_fields_is_ok_envelope_no_server_call() {
// No mounts: a no-op update must not contact the server.
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&["release", "update", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
assert_eq!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// publish — fabricated payload (Pattern 1: no double nest)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn publish_emits_ok_release_payload() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("{BASE}/v1.0.0/publish")))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "publish", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert_eq!(v["release"]["draft"], Value::Bool(false));
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
}
// ---------------------------------------------------------------------------
// delete / delete-asset — deleted envelope (Pattern contract)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn delete_emits_deleted_tag() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "delete", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(
v,
serde_json::json!({"ok": true, "deleted": "v1.0.0"}),
"{v}"
);
}
#[tokio::test]
async fn delete_asset_emits_deleted_id() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("{BASE}/v1.0.0/assets/a1")))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "delete-asset", "v1.0.0", "a1", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!({"ok": true, "deleted": "a1"}), "{v}");
}
// ---------------------------------------------------------------------------
// assets — echo array + empty + null-collection (Patterns 2/6/3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn assets_empty_is_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": []
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "assets", "v1.0.0", "--repo", REPO],
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("(none)"),
"human '(none)' leaked: {stdout}"
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!([]), "{v}");
}
/// FIXED (Pattern 3/6): same present-but-null shape as `list`. `{"assets": null}`
/// now normalizes to an empty JSON array instead of a bare `null`.
/// src/commands/release.rs:617-621, 641
#[tokio::test]
async fn assets_null_collection_emits_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": null
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "assets", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert!(
v.is_array() && v.as_array().unwrap().is_empty(),
"expected []: {v}"
);
assert_eq!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// upload — double-nest (Pattern 1) + file-not-found error (Pattern 4)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn upload_wrapped_asset_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"asset": {"filename": "art.bin", "size_bytes": 10, "short_id": "as1"}
})))
.mount(&server)
.await;
let file = std::env::temp_dir().join(format!("anvil-adv-upload-{}.bin", unique_suffix()));
std::fs::write(&file, b"0123456789").unwrap();
let out = run_json(
&server.uri(),
&[
"release",
"upload",
"v1.0.0",
file.to_str().unwrap(),
"--repo",
REPO,
],
);
let _ = std::fs::remove_file(&file);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
assert!(v["asset"]["asset"].is_null(), "double-nested: {v}");
assert_eq!(v["asset"]["filename"], "art.bin");
}
#[tokio::test]
async fn upload_missing_file_is_error_envelope_nonzero() {
let server = MockServer::start().await;
let missing = std::env::temp_dir().join(format!("anvil-adv-nope-{}.bin", unique_suffix()));
let out = run_json(
&server.uri(),
&[
"release",
"upload",
"v1.0.0",
missing.to_str().unwrap(),
"--repo",
REPO,
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert!(v.get("error").is_some(), "{v}");
assert_ne!(exit_code(&out), 0);
}
// ---------------------------------------------------------------------------
// download — reads assets then downloads (Pattern 7: result is a JSON field)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn download_emits_ok_downloaded_field() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": [{"filename": "art.bin", "short_id": "as1"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets/as1/download")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"payloadbytes".to_vec()))
.mount(&server)
.await;
let dest = std::env::temp_dir().join(format!("anvil-adv-dl-{}.bin", unique_suffix()));
let out = run_json(
&server.uri(),
&[
"release",
"download",
"v1.0.0",
"art.bin",
"--repo",
REPO,
"--output",
dest.to_str().unwrap(),
],
);
let file_written = dest.exists();
let _ = std::fs::remove_file(&dest);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
// The downloaded bytes must NOT be dumped to stdout; only a JSON field.
assert!(v["downloaded"]["file"].is_string(), "{v}");
assert!(
file_written,
"download should have written the file to --output"
);
}
// ---------------------------------------------------------------------------
// changelog — echo (Pattern 7: text lives in a JSON field, not raw stdout)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn changelog_text_is_json_not_raw_stdout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/changelog")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"changelog": "- fix a bug\n- add a feature"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "changelog", "v1.0.0", "v2.0.0", "--repo", REPO],
);
// Whole stdout must parse as one JSON value (no leading human header line).
let v = stdout_json(&out);
assert_eq!(v["changelog"], "- fix a bug\n- add a feature", "{v}");
}
#[tokio::test]
async fn changelog_server_error_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/changelog")))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "boom"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "changelog", "v1.0.0", "v2.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert_ne!(exit_code(&out), 0);
}