ref:main
//! Adversarial JSON-contract tests for the `anvil ci` command group.
//!
//! Contract (src/output.rs): under --json, stdout is exactly one JSON value.
//! - read echoing a server object/array -> bare payload
//! - client-built list -> {"items":[...]}
//! - mutation -> {"ok":true,<noun>:<value>}
//! - error -> {"ok":false,"error":...} + nonzero exit
//!
//! These tests probe edge cases (wrapped vs flat server envelopes, empty
//! collections, error status codes, log capture) for shape bugs introduced by
//! the JSON migration in src/commands/ci.rs.
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 {
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")
}
/// Parse the child's stdout as a single JSON value, failing loudly (with the
/// raw bytes) if anything non-JSON leaked onto stdout.
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}).\n--- stdout ---\n{s}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
// ---------------------------------------------------------------------------
// ci list -> read echoing server payload (bare)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_echoes_server_envelope_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_runs": [{"short_id": "ci_1", "status": "passed", "commit_sha": "deadbeefcafe"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "list", "myorg/myrepo"]);
assert!(out.status.success(), "expected exit 0");
let v = stdout_json(&out);
// read -> bare server payload echoed verbatim.
assert!(v["ci_runs"].is_array(), "expected ci_runs array, got {v}");
assert_eq!(v["ci_runs"][0]["short_id"], "ci_1");
// Not wrapped in an {"items":...} envelope.
assert!(v.get("items").is_none(), "read must not wrap in items: {v}");
}
#[tokio::test]
async fn list_empty_is_valid_json_not_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ci_runs": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "list", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out); // must parse: no "(none)" human line leaked
assert!(v["ci_runs"].is_array());
assert_eq!(v["ci_runs"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn list_server_error_yields_error_envelope_and_nonzero_exit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.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(), &["ci", "list", "myorg/myrepo"]);
assert!(!out.status.success(), "5xx must produce nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "expected {{ok:false}} envelope, got {v}");
assert!(v.get("error").is_some(), "expected error field, got {v}");
}
// ---------------------------------------------------------------------------
// ci view -> read echoing server payload (bare)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn view_echoes_wrapped_body_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_7"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_7", "status": "running", "branch": "main"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "view", "ci_7", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
// read echoes verbatim; the server envelope is preserved.
assert_eq!(v["ci_run"]["short_id"], "ci_7");
}
#[tokio::test]
async fn view_404_yields_error_envelope_and_nonzero_exit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs/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(),
&["ci", "view", "missing", "--repo", "myorg/myrepo"],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ---------------------------------------------------------------------------
// ci run (trigger) -> mutation {"ok":true,"run":<run>}; unwraps ci_run/data
// ---------------------------------------------------------------------------
#[tokio::test]
async fn trigger_unwraps_ci_run_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_new", "id": "uuid-1", "status": "queued"}
})))
.mount(&server)
.await;
// --branch avoids shelling out to git for the branch name.
let out = run_json(
&server.uri(),
&["ci", "run", "myorg/myrepo", "--branch", "feature-x"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// Envelope unwrapped: the run object is directly under "run".
assert_eq!(
v["run"]["short_id"], "ci_new",
"expected unwrapped run, got {v}"
);
// No double nest.
assert!(v["run"]["ci_run"].is_null(), "double-nested ci_run: {v}");
}
#[tokio::test]
async fn trigger_flat_body_is_wrapped_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"short_id": "ci_flat", "status": "queued"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "run", "myorg/myrepo", "--branch", "main"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["run"]["short_id"], "ci_flat");
}
// ---------------------------------------------------------------------------
// ci cancel -> mutation {"ok":true,"run":<run>}
//
// BUG PROBE (double-nest / wrong container): cancel() does
// Response::ok("run", resp)
// with the FULL server body and — unlike trigger()/set_secret() — never unwraps
// a `ci_run`/`data` envelope. cancel() also reads resp.get("status") /
// resp.get("short_id") FLAT, so if the /ci/runs/{id}/cancel endpoint returns the
// updated run wrapped in `ci_run` (as GET /ci/runs/{id} does), both the human
// summary and the JSON value are wrong: the "run" field then contains the
// envelope, not the run.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn cancel_flat_body_is_correct() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_9/cancel"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"short_id": "ci_9", "status": "cancelled"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "cancel", "ci_9", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// With a flat body the run object is correctly reachable.
assert_eq!(v["run"]["short_id"], "ci_9");
}
/// A `ci_run`-wrapped cancel body — the same envelope style GET/POST /ci/runs
/// use elsewhere — is unwrapped before Response::ok("run", ...), so the run
/// object is directly reachable under "run" with no double-nest. Mirrors
/// trigger_unwraps_ci_run_envelope_no_double_nest.
#[tokio::test]
async fn cancel_unwraps_ci_run_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_9/cancel"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_9", "status": "cancelled"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "cancel", "ci_9", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// Envelope unwrapped: the run object lives directly under "run".
assert_eq!(
v["run"]["status"], "cancelled",
"expected unwrapped run, got {v}"
);
assert_eq!(
v["run"]["short_id"], "ci_9",
"expected unwrapped run, got {v}"
);
// No double nest.
assert!(v["run"]["ci_run"].is_null(), "double-nested ci_run: {v}");
}
// ---------------------------------------------------------------------------
// ci secrets -> client-built list {"items":[...]}
// ---------------------------------------------------------------------------
#[tokio::test]
async fn secrets_uses_items_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"secrets": [{"name": "TOKEN", "environment": "prod"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["items"].is_array(),
"client-built list must use items: {v}"
);
assert_eq!(v["items"][0]["name"], "TOKEN");
}
#[tokio::test]
async fn secrets_empty_is_items_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"secrets": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out); // no "(none)" leak
assert_eq!(v["items"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn secrets_missing_key_yields_empty_items_not_null() {
// Server omits the expected "secrets" key entirely.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"unexpected": 1})),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Discarded-key path degrades to an empty items list, never null garbage.
assert!(v["items"].is_array(), "expected items array, got {v}");
assert_eq!(v["items"].as_array().unwrap().len(), 0);
}
// ---------------------------------------------------------------------------
// ci set-secret -> mutation {"ok":true,"secret":<secret>}; unwraps "secret"
// ---------------------------------------------------------------------------
#[tokio::test]
async fn set_secret_unwraps_secret_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"secret": {"name": "TOKEN", "environment": "prod"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"set-secret",
"--name",
"TOKEN",
"--value",
"s3cret",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(
v["secret"]["name"], "TOKEN",
"expected unwrapped secret: {v}"
);
assert!(v["secret"]["secret"].is_null(), "double-nested secret: {v}");
}
#[tokio::test]
async fn set_secret_flat_body_is_wrapped_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"name": "TOKEN", "environment": "*"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"set-secret",
"--name",
"TOKEN",
"--value",
"v",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["secret"]["name"], "TOKEN");
}
// ---------------------------------------------------------------------------
// ci delete-secret -> mutation {"ok":true,"secret":{name,deleted:true}}
// ---------------------------------------------------------------------------
#[tokio::test]
async fn delete_secret_synthesizes_confirmation() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/myorg/myrepo/ci/secrets/TOKEN"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"delete-secret",
"--name",
"TOKEN",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// NOTE: contract's delete envelope is {"ok":true,"deleted":"<id>"} (Response::deleted),
// but delete_secret uses Response::ok("secret", {name,deleted:true}). Documented here.
assert_eq!(v["secret"]["name"], "TOKEN");
assert_eq!(v["secret"]["deleted"], true);
}
#[tokio::test]
async fn delete_secret_error_yields_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/myorg/myrepo/ci/secrets/MISSING"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"no such secret"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"delete-secret",
"--name",
"MISSING",
"--repo",
"myorg/myrepo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ---------------------------------------------------------------------------
// ci job-view -> CAPTURE: streamed logs must be a JSON field, not raw stdout
// ---------------------------------------------------------------------------
#[tokio::test]
async fn job_view_captures_logs_into_json_field() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_1", "name": "build", "status": "passed"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_1/logs"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(
"event: log_line\ndata: {\"content\":\"compiling anvil\",\"stream\":\"stdout\"}\n\n\
event: log_line\ndata: {\"content\":\"done\",\"stream\":\"stderr\"}\n\n\
event: done\ndata: {\"status\":\"passed\",\"exit_code\":0}\n\n",
),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_1",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
// The whole of stdout must be one JSON value: proof the log lines were NOT
// dumped raw onto stdout.
let v = stdout_json(&out);
assert_eq!(v["job"]["short_id"], "job_1", "job metadata field: {v}");
let logs = v["logs"].as_array().expect("logs must be an array field");
assert_eq!(logs.len(), 2, "captured log lines: {v}");
assert_eq!(logs[0]["content"], "compiling anvil");
assert_eq!(logs[0]["stream"], "stdout");
assert_eq!(logs[1]["stream"], "stderr");
// job field is unwrapped (no job.job double nest).
assert!(v["job"]["job"].is_null(), "double-nested job: {v}");
}
#[tokio::test]
async fn job_view_log_fetch_failure_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_2", "status": "running"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_2/logs"))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"log backend down"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_2",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
// Metadata succeeded but logs failed -> whole command must fail cleanly.
assert!(!out.status.success(), "log fetch failure must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "expected error envelope, got {v}");
// The successfully-fetched metadata must NOT have leaked as human text.
assert!(
v["job"].is_null(),
"partial metadata leaked onto stdout: {v}"
);
}
#[tokio::test]
async fn job_view_empty_logs_is_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_3"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_3", "status": "passed"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_3/logs"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string("event: done\ndata: {\"status\":\"passed\",\"exit_code\":0}\n\n"),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_3",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["logs"].is_array());
assert_eq!(v["logs"].as_array().unwrap().len(), 0);
}