ref:main
//! Adversarial JSON-contract tests for the auth / repo / commit / branch surface.
//!
//! Every test drives the real `anvil` binary with `--json` against a mock Anvil
//! server and asserts the exact envelope shape the contract in `src/output.rs`
//! promises. `ANVIL_CONFIG` is pointed at a throwaway file per run so commands
//! that write/delete the config (rotate, set-default, logout) never touch the
//! developer's real credentials.
use serde_json::{json, Value};
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Run the binary under `--json`, isolating the on-disk config so mutating
/// commands can't clobber real credentials. Returns the raw process Output.
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")
}
/// Parse stdout as exactly one JSON value (the core contract) and return it.
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)
)
})
}
// ---------------------------------------------------------------------------
// auth status (no server call; reads config only)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn auth_status_logged_in_is_bare_read() {
// With ANVIL_TOKEN + ANVIL_SERVER_URL set, status reports logged_in.
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()));
// No {"ok":...} wrapper — status is a read, not a mutation.
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)"
);
}
// ---------------------------------------------------------------------------
// auth logout (deletes config file)
// ---------------------------------------------------------------------------
#[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);
// Contract: mutation -> {"ok":true,<noun>:<value>}
assert_eq!(v["ok"], json!(true));
// noun is "logout" -> {"removed": bool}; no double-nest under "logout".
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}");
}
// ---------------------------------------------------------------------------
// auth rotate (POST /user/tokens/rotate)
// ---------------------------------------------------------------------------
#[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));
// noun "rotated" holds the server body verbatim; the real token is reachable.
assert_eq!(v["rotated"]["token"], json!("anvil_newtok"));
assert_eq!(v["rotated"]["revoked"]["token_prefix"], json!("old12345"));
// No double-nest of the noun.
assert!(v["rotated"]["rotated"].is_null(), "no double-nest: {v}");
}
#[tokio::test]
async fn auth_rotate_without_yes_under_json_is_error_envelope() {
// Under --json, rotate refuses to prompt and must return the error envelope,
// NOT prompt and NOT exit 0. It must not hit the server.
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() {
// Server accepts rotation but returns no token -> command must error
// (old token is dead), producing {"ok":false,...} + nonzero exit.
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}"
);
}
// ---------------------------------------------------------------------------
// repo list (GET /repos ; client-built list -> {"items":[...]})
// ---------------------------------------------------------------------------
#[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);
// Contract: client-built list -> {"items":[...]}, never a bare array.
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}"
);
// Ensure the human "(none)" table line never leaked to stdout.
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}");
}
// ---------------------------------------------------------------------------
// repo view (GET /{org}/{repo} ; bare read echo)
// ---------------------------------------------------------------------------
#[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);
// Bare echo — no ok wrapper, fields reachable at top level.
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}");
}
// ---------------------------------------------------------------------------
// repo create (POST /{org}/repos ; {"ok":true,"repo":<value>})
// ---------------------------------------------------------------------------
#[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));
// repo field holds the created object; the slug is reachable, no double-nest.
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}");
}
// ---------------------------------------------------------------------------
// repo set-default (no server call; writes config)
// ---------------------------------------------------------------------------
#[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));
// noun default_repo is a bare string per the source.
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;
// No slash -> resolve_repo rejects the format before any config write.
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}");
}
// ---------------------------------------------------------------------------
// commit list (GET /{org}/{repo}/commits ; read echo)
// ---------------------------------------------------------------------------
#[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);
// Contract: read echoes the server object verbatim.
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}");
}
// ---------------------------------------------------------------------------
// branch list (GET /{org}/{repo}/branches ; read echo)
// ---------------------------------------------------------------------------
#[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}");
}