ref:main
//! Adversarial JSON-contract tests for `agent`, `ssh-key`, and `update`.
//!
//! Hunts the migration bug patterns: double-nest, wrong container, discarded
//! keys, error-path masking, empty-list leaks, and crashes. Each test either
//! asserts the contract holds, or documents a CONFIRMED bug in the command code
//! (kept green so the suite compiles/passes).
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 {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout is not valid JSON ({e}):\nSTDOUT:\n{s}\nSTDERR:\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
// ─────────────────────────────────────────────────────────────────────────────
// agent trigger (POST /api/v1/{org}/{repo}/agents/{name}/trigger)
// ─────────────────────────────────────────────────────────────────────────────
// Happy path: mutation envelope {"ok":true,"agent":<server body>}. Assert the
// whole server body is reachable under "agent" and there is NO double nest
// (v.agent.agent must be null since the body has no "agent" key).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_trigger_ok_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot/trigger"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"session": { "id": "sess_abc123", "status": "running" }
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"trigger",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// Session data reachable under the "agent" noun.
assert_eq!(v["agent"]["session"]["id"], "sess_abc123");
// No double-nest.
assert!(v["agent"]["agent"].is_null(), "unexpected double nest: {v}");
}
// Error path: server 500 -> stdout must be {"ok":false,"error":...} + nonzero
// exit, never exit 0 masking the failure and never a human line.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_trigger_server_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot/trigger"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({"error":"boom"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"trigger",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "expected nonzero exit on 500");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}
// ─────────────────────────────────────────────────────────────────────────────
// agent list (GET /api/v1/{org}/{repo}/agents) positional repo
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"agents": [{"name":"deploy-bot","description":"deploys","trigger":"manual"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["agent", "list", "test-org/test-repo"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["agents"][0]["name"], "deploy-bot");
}
// Empty collection must be valid JSON, never a human "(none)" line on stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_list_empty_is_clean_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"agents": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["agent", "list", "test-org/test-repo"]);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert!(v["agents"].as_array().unwrap().is_empty());
}
// ─────────────────────────────────────────────────────────────────────────────
// agent view (GET /api/v1/{org}/{repo}/agents/{name})
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_view_echoes_wrapped_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/agents/deploy-bot"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"agent": {"name":"deploy-bot","description":"d","trigger":"manual","model":"opus"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"view",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
// Read command echoes the server body verbatim (bare payload).
assert_eq!(v["agent"]["name"], "deploy-bot");
}
// ─────────────────────────────────────────────────────────────────────────────
// agent sessions (GET /api/v1/{org}/{repo}/agents/{name}/sessions)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_sessions_empty_is_clean_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/test-repo/agents/deploy-bot/sessions",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"sessions": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"sessions",
"deploy-bot",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert!(v["sessions"].as_array().unwrap().is_empty());
}
// ─────────────────────────────────────────────────────────────────────────────
// agent session (GET /api/v1/{org}/{repo}/agents/sessions/{id})
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_session_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session": {"id":"sess_abc123","status":"running","agent_name":"deploy-bot"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"session",
"sess_abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["session"]["id"], "sess_abc123");
}
// ─────────────────────────────────────────────────────────────────────────────
// agent approve (POST /api/v1/{org}/{repo}/agents/sessions/{id}/approve)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_approve_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123/approve",
))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"approve",
"sess_abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["approved"]["session"], "sess_abc123");
}
// CONFIRMED BUG (agent.rs:290, and identically :312 reject, :255 session):
// `&id[..8.min(id.len())]` byte-slices the session id to 8 bytes for the
// human confirmation string. That slice argument is evaluated even under
// --json (output::success only no-ops the *print*, not the format! that
// builds its argument). A session id whose byte 8 falls inside a multibyte
// UTF-8 char panics ("byte index 8 is not a char boundary"). The process is
// killed by the panic: stdout gets NO JSON (contract requires {"ok":false}
// on failure under --json), only a Rust panic on stderr.
//
// FIXED: the id truncation is now char-safe (id.chars().take(8)), so a
// multibyte id no longer panics — stdout stays valid JSON.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_approve_multibyte_id_stays_valid_json() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
// "€€€" = 9 bytes; byte 8 falls inside the third '€'. A byte-slice would
// panic here; the char-based truncation must not.
let out = run_json(
&server.uri(),
&["agent", "approve", "€€€", "--repo", "test-org/test-repo"],
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("char boundary") && !stderr.contains("panic"),
"must not panic on a multibyte id; stderr:\n{stderr}"
);
// Whatever the outcome, stdout is exactly one JSON value.
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
serde_json::from_str::<Value>(&stdout).is_ok(),
"stdout must be valid JSON; got: {stdout}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// agent reject (POST /api/v1/{org}/{repo}/agents/sessions/{id}/reject)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_reject_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/agents/sessions/sess_abc123/reject",
))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"agent",
"reject",
"sess_abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["rejected"]["session"], "sess_abc123");
}
// ─────────────────────────────────────────────────────────────────────────────
// ssh-key list (GET /api/v1/user/ssh-keys) -> client-built {"items":[...]}
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_list_items_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ssh_keys": [{"id":"k1","name":"laptop","fingerprint":"SHA256:aaa"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "list"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
// Contract: client-built list -> {"items":[...]}, not a bare array.
assert!(v["items"].is_array(), "expected items envelope: {v}");
assert_eq!(v["items"][0]["id"], "k1");
assert_eq!(v["items"][0]["fingerprint"], "SHA256:aaa");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_list_empty_is_items_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ssh_keys": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "list"]);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human placeholder leaked: {s}");
let v = stdout_json(&out);
assert_eq!(v["items"], json!([]));
}
// ─────────────────────────────────────────────────────────────────────────────
// ssh-key add (POST /api/v1/user/ssh-keys) -> {"ok":true,"ssh_key":<body>}
// ─────────────────────────────────────────────────────────────────────────────
// The codebase mutation convention (see create_requirement in json_output.rs)
// is a FLAT server body wrapped by Response::ok. add() reads resp.get(
// "fingerprint") at TOP level, confirming it assumes flat. Assert flat body
// wraps correctly with no double nest and the fingerprint reachable.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_add_flat_body_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id":"k1","name":"laptop","fingerprint":"SHA256:bbb"
})))
.mount(&server)
.await;
let key_file = std::env::temp_dir().join("adv_ssh_key_flat.pub");
std::fs::write(&key_file, "ssh-ed25519 AAAAC3Nz test@host\n").unwrap();
let out = run_json(
&server.uri(),
&[
"ssh-key",
"add",
"--name",
"laptop",
"--key-file",
key_file.to_str().unwrap(),
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["ssh_key"]["fingerprint"], "SHA256:bbb");
// No double nest under the flat convention.
assert!(
v["ssh_key"]["ssh_key"].is_null(),
"unexpected double nest: {v}"
);
}
// Latent-risk documentation (NOT reported as a confirmed bug): add() does not
// unwrap a "ssh_key"-wrapped server body. IF the server ever returned
// {"ssh_key":{...}} (as the plural list endpoint wraps under "ssh_keys"), the
// output would double-nest to {"ok":true,"ssh_key":{"ssh_key":{...}}} AND the
// human fingerprint line would read "unknown". The mutation convention in this
// repo is a flat body, so this is a latent hazard, not an active bug. This test
// pins the CURRENT behavior so a future contract change is caught.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_add_wrapped_body_current_behavior() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/user/ssh-keys"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"ssh_key": {"id":"k1","name":"laptop","fingerprint":"SHA256:ccc"}
})))
.mount(&server)
.await;
let key_file = std::env::temp_dir().join("adv_ssh_key_wrapped.pub");
std::fs::write(&key_file, "ssh-ed25519 AAAAC3Nz test@host\n").unwrap();
let out = run_json(
&server.uri(),
&[
"ssh-key",
"add",
"--name",
"laptop",
"--key-file",
key_file.to_str().unwrap(),
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
// CURRENT behavior: no unwrap, so the wrap is preserved verbatim.
assert_eq!(v["ssh_key"]["ssh_key"]["fingerprint"], "SHA256:ccc");
}
// ─────────────────────────────────────────────────────────────────────────────
// ssh-key remove (DELETE /api/v1/user/ssh-keys/{id})
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_remove_deleted_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/user/ssh-keys/k1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "remove", "k1"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "k1");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ssh_key_remove_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/user/ssh-keys/nope"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error":"not found"})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ssh-key", "remove", "nope"]);
assert!(!out.status.success(), "expected nonzero exit on 404");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}
// ─────────────────────────────────────────────────────────────────────────────
// update --check (GET {server}/runner/version — NO /api/v1 prefix)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_reports_available_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"version": "9999.0.0",
"platforms": ["linux_amd64"]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["update", "--check"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert!(v["current"].is_string(), "current missing: {v}");
assert_eq!(v["latest"], "9999.0.0");
assert_eq!(v["update_available"], true);
}
// Error path: /runner/version returns 500 -> the reqwest .json() fails, the
// error propagates, and main emits {"ok":false,"error":...} with nonzero exit.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_server_error_is_json_error_and_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(500).set_body_string("kaboom"))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["update", "--check"]);
assert!(!out.status.success(), "expected nonzero exit on 500");
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
assert!(v["error"].is_string(), "error field missing: {v}");
}