ref:main
//! Adversarial JSON-contract tests for `anvil runner` (admin + local
//! subcommands). We drive the real binary with `--json`, point it at a
//! wiremock server via `ANVIL_SERVER_URL`, and assert the exact envelope
//! shape the output contract promises.
//!
//! Commands attacked: list / view / status / doctor / token / stop /
//! remove / update. (start/restart/configure/service are skipped — they
//! hang or mutate host state.)
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")
}
/// Parse stdout as JSON, failing loudly with the raw bytes on error.
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 valid JSON ({e}):\n{s}"))
}
// ============================================================
// list -> GET /api/v1/runners/orgs/{org} | /repos/{o}/{r}
// echoes the server payload verbatim (Response::read)
// ============================================================
#[tokio::test]
async fn list_org_echoes_server_payload() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/orgs/acme"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"runners": [{"id": "r1", "name": "box", "status": "online"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "list", "--org", "acme"]);
assert!(out.status.success(), "expected exit 0");
let v = stdout_json(&out);
// Read echoes the bare server object — the {"runners":[...]} wrapper stays.
assert!(v["runners"].is_array(), "runners array must survive: {v}");
assert_eq!(v["runners"][0]["id"], "r1");
// Must NOT be re-wrapped into {"items":...} or {"ok":...}.
assert!(v.get("items").is_none());
assert!(v.get("ok").is_none());
}
#[tokio::test]
async fn list_empty_is_json_not_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/orgs/empty"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"runners": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "list", "--org", "empty"]);
assert!(out.status.success());
let v = stdout_json(&out);
// Empty collection must be a JSON empty array, never a human "(none)".
assert_eq!(v["runners"].as_array().map(|a| a.len()), Some(0));
let raw = String::from_utf8_lossy(&out.stdout);
assert!(!raw.contains("(none)"), "human placeholder leaked: {raw}");
}
#[tokio::test]
async fn list_repo_error_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/repos/acme/widgets"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "boom"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "list", "--repo", "acme/widgets"]);
assert!(!out.status.success(), "5xx must yield nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "error must be {{\"ok\":false,...}}: {v}");
assert!(v.get("error").is_some());
}
#[tokio::test]
async fn list_without_target_is_error_envelope() {
// No server call happens; the command Errs before touching the network.
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["runner", "list"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ============================================================
// view -> GET /api/v1/runners/{id}
// unwraps {"runner":..}/{"data":..}, echoes bare (Response::read)
// ============================================================
#[tokio::test]
async fn view_unwraps_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"runner": {"id": "r1", "name": "box", "status": "online"}
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "view", "r1"]);
assert!(out.status.success());
let v = stdout_json(&out);
// The inner runner object is echoed bare — no {"runner":{"runner":..}}.
assert_eq!(v["id"], "r1");
assert_eq!(v["name"], "box");
assert!(v.get("runner").is_none(), "must not still be wrapped: {v}");
}
#[tokio::test]
async fn view_flat_body_echoed_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/r2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "r2", "name": "flat", "arch": "x86_64"
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "view", "r2"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["id"], "r2");
assert_eq!(v["arch"], "x86_64");
}
#[tokio::test]
async fn view_404_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/runners/missing"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not found"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "view", "missing"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ============================================================
// update -> PATCH /api/v1/runners/{id}
// {"ok":true,"runner":<unwrapped>} (Response::ok)
// ============================================================
#[tokio::test]
async fn update_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/runners/r1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"runner": {"id": "r1", "name": "renamed"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["runner", "update", "r1", "--name", "renamed"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// Correct unwrap: runner.name reachable, no {"runner":{"runner":..}}.
assert_eq!(v["runner"]["id"], "r1");
assert_eq!(v["runner"]["name"], "renamed");
assert!(v["runner"]["runner"].is_null(), "double-nest detected: {v}");
}
#[tokio::test]
async fn update_flat_body_wrapped_once() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/runners/r3"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "r3", "labels": "gpu"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["runner", "update", "r3", "--labels", "gpu"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["runner"]["id"], "r3");
assert_eq!(v["runner"]["labels"], "gpu");
}
#[tokio::test]
async fn update_500_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/runners/r4"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "nope"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "update", "r4", "--name", "x"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
/// FIXED: `update()` now truncates the id char-safely (`id.chars().take(8)`),
/// so a multibyte id no longer slices mid-character and no longer panics. The
/// command completes cleanly and emits its normal JSON envelope on stdout.
#[tokio::test]
async fn update_multibyte_id_stays_valid_json() {
let server = MockServer::start().await;
// Match the PATCH regardless of the (percent-encoded) path.
Mock::given(method("PATCH"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "x"})))
.mount(&server)
.await;
// 6 CJK chars = 18 bytes; byte index 8 is mid-character (would have panicked).
let out = run_json(
&server.uri(),
&["runner", "update", "日本語日本語", "--name", "x"],
);
assert!(out.status.success(), "char-safe truncation should exit 0");
// Valid JSON on stdout, and the normal success envelope.
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// No panic leaked to stderr.
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("char boundary") && !err.contains("panic"),
"must not panic on a multibyte id, got stderr: {err}"
);
}
// ============================================================
// remove -> DELETE /api/v1/runners/{id}
// {"ok":true,"deleted":"<id>"} (Response::deleted)
// ============================================================
#[tokio::test]
async fn remove_happy_deleted_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/runners/runner-12345"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "remove", "runner-12345"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "runner-12345");
}
#[tokio::test]
async fn remove_404_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/runners/gone"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "gone"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "remove", "gone"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
/// FIXED (same root cause as update): `remove()` now truncates the id
/// char-safely, so a multibyte id no longer panics. The command emits its
/// normal `{"ok":true,"deleted":..}` envelope as valid JSON.
#[tokio::test]
async fn remove_multibyte_id_stays_valid_json() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "remove", "日本語日本語"]);
assert!(out.status.success(), "char-safe truncation should exit 0");
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "日本語日本語");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("char boundary") && !err.contains("panic"),
"must not panic on a multibyte id, got stderr: {err}"
);
}
// ============================================================
// token -> POST /api/v1/runners/orgs/{org}/tokens | repos/.../tokens
// {"ok":true,"token":"<value>"} (Response::ok)
// ============================================================
#[tokio::test]
async fn token_org_nested_value_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/orgs/acme/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"token": {"value": "tok-abc"}
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "token", "--org", "acme"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["token"], "tok-abc");
}
#[tokio::test]
async fn token_flat_string_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/orgs/acme/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "tok-flat"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "token", "--org", "acme"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["token"], "tok-flat");
}
#[tokio::test]
async fn token_repo_data_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/repos/acme/widgets/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {"token": "tok-data"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["runner", "token", "--repo", "acme/widgets"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["token"], "tok-data");
}
/// FIXED: when the server body carries the token under none of the three
/// recognized shapes, the command now fails loudly instead of persisting a
/// placeholder `"?"`. It errors with a nonzero exit and a `{"ok":false,...}`
/// envelope whose error mentions the missing token — never a `"?"` credential.
#[tokio::test]
async fn token_missing_key_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/orgs/acme/tokens"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"unexpected": "shape"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "token", "--org", "acme"]);
assert!(
!out.status.success(),
"missing token must yield nonzero exit"
);
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
let err = v["error"].as_str().unwrap_or("");
assert!(
err.to_lowercase().contains("token"),
"error should mention the missing token: {v}"
);
// No placeholder credential must appear anywhere in the envelope.
assert!(v.get("token").is_none(), "no token key on an error: {v}");
assert_ne!(v["token"], "?");
}
#[tokio::test]
async fn token_500_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/runners/orgs/acme/tokens"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error": "nope"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["runner", "token", "--org", "acme"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
#[tokio::test]
async fn token_bad_repo_format_is_error_envelope() {
let server = MockServer::start().await;
let out = run_json(&server.uri(), &["runner", "token", "--repo", "no-slash"]);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
// ============================================================
// status -> LOCAL only, no server call. Response::read(body)
// ============================================================
#[tokio::test]
async fn status_local_emits_json_not_human() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"runner",
"status",
"--config",
"/nonexistent/adv/config.json",
"--pid-file",
"/nonexistent/adv/runner.pid",
"--service-name",
"adv-test-nope",
],
);
assert!(
out.status.success(),
"status reads local state, should exit 0"
);
let v = stdout_json(&out);
assert_eq!(v["configured"], false);
// running.alive must be a bool, not missing/garbage.
assert_eq!(v["running"]["alive"], false);
let raw = String::from_utf8_lossy(&out.stdout);
assert!(!raw.contains("Runner Status"), "human header leaked: {raw}");
}
// ============================================================
// doctor -> GATE. A failing check must EMIT its JSON payload, THEN
// exit nonzero. Payload is a bare array (Response::gate).
// ============================================================
#[tokio::test]
async fn doctor_failing_gate_emits_json_then_nonzero() {
let server = MockServer::start().await;
// Config unreadable -> config_ok=false -> gate(payload, 1, ...).
let out = run_json(
&server.uri(),
&[
"runner",
"doctor",
"--config",
"/nonexistent/adv/config.json",
"--pid-file",
"/nonexistent/adv/runner.pid",
"--service-name",
"adv-test-nope",
],
);
assert!(!out.status.success(), "failing gate must exit nonzero");
let v = stdout_json(&out);
// Payload is the bare checks array — still valid JSON on stdout.
assert!(v.is_array(), "doctor payload must be a JSON array: {v}");
let arr = v.as_array().unwrap();
assert!(!arr.is_empty());
// The config-readable check must have failed.
let cfg = arr
.iter()
.find(|c| c["check"] == "Config readable")
.unwrap();
assert_eq!(cfg["status"], "error");
// Must NOT have been replaced by a {"ok":false,...} error doc.
assert!(
v.get("ok").is_none(),
"gate should emit payload, not error env: {v}"
);
}
#[tokio::test]
async fn doctor_healthy_gate_passes_exit_zero() {
let server = MockServer::start().await;
// runner id the config will carry; doctor GETs /runners/{id}.
Mock::given(method("GET"))
.and(path("/api/v1/runners/r-doc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "r-doc"})))
.mount(&server)
.await;
// Write a runner config pointing at the mock server.
let dir = std::env::temp_dir().join(format!("adv-runner-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg_path = dir.join("config.json");
let cfg = json!({
"server_url": server.uri(),
"runner_id": "r-doc",
"runner_token": "rt",
"name": "n",
"labels": ["self-hosted"],
"work_dir": "/tmp/wd",
"parallel": 1
});
std::fs::write(&cfg_path, serde_json::to_string(&cfg).unwrap()).unwrap();
let out = run_json(
&server.uri(),
&[
"runner",
"doctor",
"--config",
cfg_path.to_str().unwrap(),
"--pid-file",
"/nonexistent/adv/runner.pid",
"--service-name",
"adv-test-nope",
],
);
let v = stdout_json(&out);
assert!(v.is_array(), "payload must be an array: {v}");
let arr = v.as_array().unwrap();
// All checks OK -> exit 0.
assert!(
out.status.success(),
"healthy doctor should exit 0; checks={v}"
);
assert!(arr.iter().all(|c| c["status"] == "ok"), "checks: {v}");
let _ = std::fs::remove_dir_all(&dir);
}
// ============================================================
// stop -> LOCAL. No PID file -> {"ok":true,"stopped":{"running":false,..}}
// ============================================================
#[tokio::test]
async fn stop_no_pidfile_is_ok_envelope() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"runner",
"stop",
"--pid-file",
"/nonexistent/adv/runner.pid",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["stopped"]["running"], false);
let raw = String::from_utf8_lossy(&out.stdout);
assert!(
!raw.contains("No runner is running"),
"human line leaked: {raw}"
);
}