ref:main
//! Adversarial `--json` contract tests for the `requirement` command surface.
//!
//! These probe the edges the JSON-output migration touched: envelope shape
//! (bare read vs `{"ok":true,…}` vs `{"items":…}`), double-nesting, gate
//! commands that must emit JSON *and* exit nonzero, error paths that must
//! surface `{"ok":false,"error":…}`, and empty-collection / missing-key cases.
//!
//! Everything asserts against **stdout parsed as JSON** plus the process exit
//! code — an agent scripting the CLI sees exactly this.
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, dumping both streams on failure so a leaked human
/// line (or an empty stdout) is easy to diagnose.
fn stdout_json(out: &Output) -> Value {
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON: {e}\n--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
})
}
// ─────────────────────────── list ───────────────────────────
/// `list` (default kind=requirement) echoes the server body bare — NOT wrapped
/// in `{"items":…}`. Contract: a read of a server object is the bare payload.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_requirements_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [{"requirement_id": "REQ-A-001", "title": "T"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "list", "--repo", "test-org/test-repo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
// bare read: top-level is the server envelope, not {"items":…}
assert!(v["requirements"].is_array(), "got: {v}");
assert!(v["items"].is_null(), "should not be wrapped in items: {v}");
assert_eq!(v["requirements"][0]["requirement_id"], "REQ-A-001");
}
/// EMPTY-LIST: an empty server collection must serialize as an empty JSON array
/// under its key — never a human "(none)" line leaking to stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_requirements_empty_is_clean_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"requirements": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "list", "--repo", "test-org/test-repo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["requirements"].as_array().map(|a| a.len()),
Some(0),
"got: {v}"
);
}
/// list --kind standard echoes the org standards body bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_standards_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"standards": [{"requirement_id": "STD-GDPR-017", "title": "Erasure"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"standard",
"--organization",
"test-org",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["standards"][0]["requirement_id"], "STD-GDPR-017",
"got: {v}"
);
}
/// HIGH-RISK: `list --kind all` merges two server bodies into one document with
/// both halves reachable and unwrapped (arrays, not `{"requirements":{...}}`).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_all_merges_both_halves() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [{"requirement_id": "REQ-A-001", "title": "R"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"standards": [{"requirement_id": "STD-B-002", "title": "S"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"all",
"--repo",
"test-org/test-repo",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
// Both halves present as arrays, not doubly-nested under their own key.
assert!(v["requirements"].is_array(), "got: {v}");
assert!(v["standards"].is_array(), "got: {v}");
assert_eq!(v["requirements"][0]["requirement_id"], "REQ-A-001");
assert_eq!(v["standards"][0]["requirement_id"], "STD-B-002");
assert!(v["requirements"][0].is_object() && v["requirements"]["requirements"].is_null());
}
/// ERROR PATH (client-side validation): `--kind all` without --organization
/// fails *before* any HTTP with a clean `{"ok":false,"error":…}` and nonzero exit.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_all_missing_org_errors() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"all",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "expected nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
assert!(v["error"].is_string(), "got: {v}");
}
// ─────────────────────────── view ───────────────────────────
/// view REQ echoes the server object bare and unwrapped.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_requirement_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-1", "requirement_id": "REQ-A-001", "title": "T",
"category": "security", "status": "active", "version": "3"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-A-001",
"--repo",
"test-org/test-repo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "REQ-A-001", "got: {v}");
assert!(
v["ok"].is_null(),
"read must be bare, not an ok-envelope: {v}"
);
}
/// FIXED (pattern #3): view REQ no longer couples JSON emission to a strict
/// display struct (`RequirementDetail`). The strict struct now drives only the
/// human table; the `--json` path echoes the raw server body verbatim. A body
/// that is perfectly valid JSON but carries `version` as a NUMBER (many servers
/// version rows with an integer) is echoed as-is with exit 0 — matching the
/// defensive behavior of `view_standard`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_requirement_echoes_unexpected_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-002"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-2", "requirement_id": "REQ-A-002", "title": "T",
"version": 2
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-A-002",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
// FIXED behavior: valid server JSON is echoed verbatim, numeric version intact.
assert_eq!(v["requirement_id"], "REQ-A-002", "got: {v}");
assert_eq!(v["version"], 2, "numeric version echoed verbatim: {v}");
assert!(
v["ok"].is_null(),
"read must be bare, not an ok-envelope: {v}"
);
}
/// view STD echoes the server object bare (and view_standard reads defensively,
/// so a numeric field here would NOT break it — contrast with the REQ case).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_standard_bare_read_defensive() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/STD-GDPR-017"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-s", "requirement_id": "STD-GDPR-017", "title": "Erasure",
"mandatory": true, "version": 7
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"STD-GDPR-017",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "STD-GDPR-017", "got: {v}");
assert_eq!(v["version"], 7, "numeric version echoed verbatim: {v}");
}
/// ERROR PATH: bad ID prefix (no REQ-/STD-) fails client-side with a clean
/// error envelope and nonzero exit — no HTTP, no human line on stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_unknown_prefix_errors() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"FOO-1",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
}
/// ERROR PATH: server 404 on a view surfaces as `{"ok":false,"error":…}` with
/// nonzero exit — not a masked exit 0, not a human line.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_requirement_404_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-404"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not found"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-A-404",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
assert!(v["error"].is_string(), "got: {v}");
}
// ─────────────────────────── create / update ───────────────────────────
/// create REQ: `{"ok":true,"requirement":{…flat…}}` — no double nest, field
/// reachable at v["requirement"]["requirement_id"].
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_requirement_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "uuid-c", "requirement_id": "REQ-A-010", "title": "New"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"REQ-A-010",
"--title",
"New",
"--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, "got: {v}");
assert_eq!(v["requirement"]["requirement_id"], "REQ-A-010", "got: {v}");
assert!(
v["requirement"]["requirement"].is_null(),
"double nest: {v}"
);
}
/// create STD: `{"ok":true,"standard":{…}}`, no double nest.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_standard_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/standards"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "uuid-s2", "requirement_id": "STD-X-001", "title": "Std"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"STD-X-001",
"--title",
"Std",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["standard"]["requirement_id"], "STD-X-001", "got: {v}");
assert!(v["standard"]["standard"].is_null(), "double nest: {v}");
}
/// ERROR PATH: server 422 on create surfaces as an error envelope + nonzero exit
/// (not a spurious `{"ok":true,…}` masking the failure).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_requirement_422_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({"error": "duplicate"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"REQ-A-011",
"--title",
"Dup",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "must not mask a 422 as success");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
}
/// update REQ: `{"ok":true,"requirement":{…}}`, no double nest.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_requirement_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-010"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-c", "requirement_id": "REQ-A-010", "title": "Renamed"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"update",
"REQ-A-010",
"--title",
"Renamed",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["requirement"]["title"], "Renamed", "got: {v}");
assert!(
v["requirement"]["requirement"].is_null(),
"double nest: {v}"
);
}
/// update STD: `{"ok":true,"standard":{…}}`, no double nest.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_standard_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/api/v1/test-org/standards/STD-X-001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "uuid-s2", "requirement_id": "STD-X-001", "title": "Std2"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"update",
"STD-X-001",
"--title",
"Std2",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["standard"]["requirement_id"], "STD-X-001", "got: {v}");
assert!(v["standard"]["standard"].is_null(), "double nest: {v}");
}
// ─────────────────────────── delete ───────────────────────────
/// delete REQ: `{"ok":true,"deleted":"REQ-…"}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_requirement_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-A-010"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"delete",
"REQ-A-010",
"--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, "got: {v}");
assert_eq!(v["deleted"], "REQ-A-010", "got: {v}");
}
/// delete STD: `{"ok":true,"deleted":"STD-…"}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_standard_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/standards/STD-X-001"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"delete",
"STD-X-001",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["deleted"], "STD-X-001", "got: {v}");
}
// ─────────────────────────── link / unlink ───────────────────────────
/// link: `{"ok":true,"link":{…server body…}}` — probe the flat server contract
/// the human path assumes; assert no double nest.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn link_test_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-A-010/links",
))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"test_name": "logintest", "test_link_id": "tl-1"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"link",
"REQ-A-010",
"--test",
"logintest",
"--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, "got: {v}");
assert_eq!(v["link"]["test_name"], "logintest", "got: {v}");
assert!(v["link"]["link"].is_null(), "double nest: {v}");
}
/// link on a STD-* ID is rejected client-side (links are requirements-only) →
/// error envelope + nonzero exit, no HTTP.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn link_standard_id_rejected() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&[
"requirement",
"link",
"STD-X-001",
"--test",
"t",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "got: {v}");
}
/// unlink: client-built `{"ok":true,"unlinked":{"requirement":…,"test":…}}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unlink_test_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-A-010/links/logintest",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"unlink",
"REQ-A-010",
"--test",
"logintest",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["unlinked"]["requirement"], "REQ-A-010", "got: {v}");
assert_eq!(v["unlinked"]["test"], "logintest", "got: {v}");
}
// ─────────────────────────── applicability ───────────────────────────
/// applicability list echoes the server `{"repositories":[…]}` bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_list_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/STD-X-001/applicabilities"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"repositories": [{"slug": "app", "name": "App", "visibility": "private"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"list",
"STD-X-001",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["repositories"][0]["slug"], "app", "got: {v}");
}
/// applicability list EMPTY: no repos opted in → `{"repositories":[]}`, no
/// "(none)" human line on stdout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_list_empty_clean() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/STD-X-001/applicabilities"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"repositories": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"list",
"STD-X-001",
"--organization",
"test-org",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(
v["repositories"].as_array().map(|a| a.len()),
Some(0),
"got: {v}"
);
}
/// applicability add: client-built `{"ok":true,"applicability":{…,"opted_in":true}}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_add_envelope() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(
"/api/v1/test-org/standards/STD-X-001/applicabilities/app",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"add",
"STD-X-001",
"--organization",
"test-org",
"--repo",
"app",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["applicability"]["opted_in"], true, "got: {v}");
assert_eq!(v["applicability"]["repo"], "app", "got: {v}");
assert!(
v["applicability"]["applicability"].is_null(),
"double nest: {v}"
);
}
/// applicability remove: `opted_in:false`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_remove_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/standards/STD-X-001/applicabilities/app",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"remove",
"STD-X-001",
"--organization",
"test-org",
"--repo",
"app",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["applicability"]["opted_in"], false, "got: {v}");
}
// ─────────────────────────── matrix ───────────────────────────
/// matrix (requirement) echoes the server `{"matrix":[…]}` bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn matrix_requirements_bare_read() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [{"requirement": {"requirement_id": "REQ-A-001", "title": "T"},
"coverage_status": "covered", "tests": []}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "matrix", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(
v["matrix"][0]["requirement"]["requirement_id"], "REQ-A-001",
"got: {v}"
);
}
/// matrix (standard) EMPTY echoes bare, no human "(no standards defined)" leak.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn matrix_standards_empty_clean() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"matrix": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"matrix",
"--kind",
"standard",
"--organization",
"test-org",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["matrix"].as_array().map(|a| a.len()), Some(0), "got: {v}");
}
// ─────────────────────────── status (GATE) ───────────────────────────
/// HIGH-RISK GATE: uncovered requirements → the JSON payload is STILL emitted on
/// stdout, and the process exits nonzero. Both must hold.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_gate_fails_but_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [
{"requirement": {"requirement_id": "REQ-A-001", "title": "Covered"},
"coverage_status": "covered"},
{"requirement": {"requirement_id": "REQ-A-002", "title": "Uncovered"},
"coverage_status": "uncovered"}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
// GATE: nonzero exit …
assert!(!out.status.success(), "uncovered must exit nonzero");
// … but a valid machine payload is on stdout anyway.
let v = stdout_json(&out);
assert_eq!(v["passed"], false, "got: {v}");
assert_eq!(v["counts"]["uncovered"], 1, "got: {v}");
assert_eq!(v["failing"][0]["requirement_id"], "REQ-A-002", "got: {v}");
// The error envelope must NOT have double-emitted onto stdout.
assert!(
v["ok"].is_null(),
"gate payload must not be an ok-envelope: {v}"
);
}
/// status passing: exit 0, `passed:true`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_gate_passes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [{"requirement": {"requirement_id": "REQ-A-001", "title": "C"},
"coverage_status": "covered"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["passed"], true, "got: {v}");
}
/// status --strict: a `partial` requirement trips the gate (nonzero) yet still
/// emits its JSON.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_strict_partial_fails_but_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [{"requirement": {"requirement_id": "REQ-A-003", "title": "P"},
"coverage_status": "partial"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict",
"--repo",
"test-org/test-repo",
],
);
assert!(!out.status.success(), "strict + partial must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["passed"], false, "got: {v}");
assert_eq!(v["counts"]["partial"], 1, "got: {v}");
}
/// status on an EMPTY matrix: empty repo passes (exit 0), warning goes to stderr,
/// stdout stays a clean machine payload.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_empty_matrix_passes_clean() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"matrix": []})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["passed"], true, "got: {v}");
assert_eq!(v["counts"]["total"], 0, "got: {v}");
}
/// HIGH-RISK GATE: `status --strict-standards` on a 422 (uncovered mandatory
/// standards) must emit `{"passed":false,"uncovered":[…]}` AND exit nonzero.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_gate_fails_but_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/strict"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"status": "uncovered",
"uncovered": [
{"standard": {"requirement_id": "STD-X-001", "title": "S"},
"repo": {"slug": "app"}, "status": "uncovered"}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict-standards",
"--organization",
"test-org",
],
);
assert!(
!out.status.success(),
"uncovered mandatory standards must exit nonzero"
);
let v = stdout_json(&out);
assert_eq!(v["passed"], false, "got: {v}");
assert_eq!(
v["uncovered"][0]["standard"]["requirement_id"], "STD-X-001",
"got: {v}"
);
assert!(
v["ok"].is_null(),
"gate payload must not be an ok-envelope: {v}"
);
}
/// status --strict-standards passing (2xx status=ok): `{"passed":true,"uncovered":[]}`,
/// exit 0.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_gate_passes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/strict"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict-standards",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["passed"], true, "got: {v}");
assert_eq!(
v["uncovered"].as_array().map(|a| a.len()),
Some(0),
"got: {v}"
);
}
// ─────────────────────────── import ───────────────────────────
/// import (apply) echoes the server body bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn import_apply_bare_read() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements/import"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"applied": {"created": 2, "updated": 1, "unchanged": 0}
})))
.mount(&server)
.await;
// Provide the import file via stdin ("-").
let dir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
let f = format!("{dir}/adversarial_import_{}.yml", std::process::id());
std::fs::write(&f, "- requirement_id: REQ-A-001\n title: T\n").unwrap();
let out = run_json(
&server.uri(),
&["requirement", "import", &f, "--repo", "test-org/test-repo"],
);
let _ = std::fs::remove_file(&f);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["applied"]["created"], 2, "got: {v}");
assert!(v["ok"].is_null(), "import read must be bare: {v}");
}
/// import --dry-run echoes the preview body bare.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn import_dry_run_bare_read() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements/import"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"preview": {"create": [{"requirement_id": "REQ-A-002"}],
"update": [], "unchanged": [], "errors": []}
})))
.mount(&server)
.await;
let dir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
let f = format!("{dir}/adversarial_import_dry_{}.yml", std::process::id());
std::fs::write(&f, "- requirement_id: REQ-A-002\n title: T\n").unwrap();
let out = run_json(
&server.uri(),
&[
"requirement",
"import",
&f,
"--dry-run",
"--repo",
"test-org/test-repo",
],
);
let _ = std::fs::remove_file(&f);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(
v["preview"]["create"][0]["requirement_id"], "REQ-A-002",
"got: {v}"
);
}