crtx 0.1.1

CLI for the Cortex supervisory memory substrate.
//! End-to-end CLI tests for `cortex release readiness --from-store` and
//! `cortex compliance evidence --from-store` (Phase 2.6 Authority Proof
//! Closure Commits C+D).
//!
//! Test plan (8+ cases per task design):
//!
//! - **Default mode unchanged**: `release readiness` without `--from-store`
//!   continues to fail closed on missing evidence input (mirror for compliance).
//! - **`--from-store` happy(-ish) path**: when no axes are wired locally, the
//!   command surfaces every missing axis with a stable invariant string and
//!   refuses to emit a trusted artifact. This is the honest reading of the
//!   task constraint: "if four-witness composition requires more than is
//!   honestly available today (e.g., no ADO build evidence integration yet),
//!   surface the gap and emit advisory output rather than overclaim."
//! - **`--from-store` signed-chain axis missing (no event log)**: every axis
//!   missing.
//! - **`--from-store` signed-chain axis missing (no key supplied)**: missing
//!   the signed_chain axis specifically when the JSONL exists but no
//!   verifying key is provided.
//! - **Stable invariants**: each missing axis carries the contracted
//!   invariant string format (`release.readiness.from_store.witness_axis_missing.<axis>`
//!   and the compliance mirror).
//!
//! Tests cover both surfaces (release + compliance) for the same set of
//! scenarios for parity.

use std::path::PathBuf;
use std::process::Command;

use serde_json::Value;
use tempfile::TempDir;

fn cortex_bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_cortex"))
}

fn run_in_data_dir(data_dir: &std::path::Path, args: &[&str]) -> std::process::Output {
    Command::new(cortex_bin())
        .env_remove("RUST_LOG")
        .env("CORTEX_DATA_DIR", data_dir)
        .args(args)
        .output()
        .expect("spawn cortex")
}

fn assert_precondition_unmet(out: &std::process::Output) {
    let code = out.status.code().expect("exited via signal");
    assert_eq!(
        code,
        7,
        "expected exit 7 (PreconditionUnmet)\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
}

fn parse_stderr_json(out: &std::process::Output) -> Value {
    let stderr = String::from_utf8_lossy(&out.stderr);
    // Find the first '{' and parse from there to end (the CLI prints the JSON
    // report to stderr in non-`--json` mode using pretty serialization).
    let start = stderr
        .find('{')
        .unwrap_or_else(|| panic!("no JSON found in stderr:\n{stderr}"));
    serde_json::from_str(&stderr[start..])
        .unwrap_or_else(|err| panic!("stderr is not valid JSON ({err}):\n{stderr}"))
}

// =============================================================================
// `release readiness --from-store`
// =============================================================================

#[test]
fn release_from_store_with_no_local_state_emits_advisory_with_all_axes_missing() {
    let tmp = TempDir::new().expect("tempdir");
    let out = run_in_data_dir(tmp.path(), &["release", "readiness", "--from-store"]);
    assert_precondition_unmet(&out);

    let report = parse_stderr_json(&out);
    assert_eq!(report["command"], "release.readiness.from_store");
    assert_eq!(report["mode"], "from_store");
    assert_eq!(report["trusted_artifact_emitted"], false);
    assert_eq!(report["all_witness_axes_present"], false);

    let missing = report["missing_witness_axes"]
        .as_array()
        .expect("missing_witness_axes is array")
        .iter()
        .filter_map(Value::as_str)
        .collect::<Vec<_>>();
    assert!(
        missing.contains(&"signed_chain"),
        "signed_chain missing: {missing:?}"
    );
    assert!(
        missing.contains(&"ado_build"),
        "ado_build missing: {missing:?}"
    );
    assert!(missing.contains(&"rekor"), "rekor missing: {missing:?}");
    assert!(missing.contains(&"ots"), "ots missing: {missing:?}");
}

#[test]
fn release_from_store_emits_stable_invariants_for_every_missing_axis() {
    let tmp = TempDir::new().expect("tempdir");
    let out = run_in_data_dir(tmp.path(), &["release", "readiness", "--from-store"]);
    let report = parse_stderr_json(&out);

    let signed_chain = &report["evidence_input"]["signed_chain"];
    assert_eq!(signed_chain["state"], "missing");
    assert_eq!(
        signed_chain["invariant"],
        "release.readiness.from_store.witness_axis_missing.signed_chain"
    );

    let ado_build = &report["evidence_input"]["ado_build"];
    assert_eq!(ado_build["state"], "missing");
    assert_eq!(
        ado_build["invariant"],
        "release.readiness.from_store.witness_axis_missing.ado_build"
    );

    let rekor = &report["evidence_input"]["rekor"];
    assert_eq!(rekor["state"], "missing");
    assert_eq!(
        rekor["invariant"],
        "release.readiness.from_store.witness_axis_missing.rekor"
    );

    let ots = &report["evidence_input"]["ots"];
    assert_eq!(ots["state"], "missing");
    assert_eq!(
        ots["invariant"],
        "release.readiness.from_store.witness_axis_missing.ots"
    );
}

#[test]
fn release_default_mode_without_from_store_still_fails_closed_on_missing_input() {
    let tmp = TempDir::new().expect("tempdir");
    let out = run_in_data_dir(tmp.path(), &["release", "readiness"]);
    assert_precondition_unmet(&out);

    let report = parse_stderr_json(&out);
    // The non-`--from-store` path is byte-for-byte unchanged from before
    // Commits C+D landed. Anchored on the command name to ensure we're
    // looking at the original report shape.
    assert_eq!(report["command"], "release.readiness");
    assert_eq!(report["trusted_artifact_emitted"], false);
    assert_eq!(
        report["evidence_input"]["failed_rule"],
        "release.evidence_input.missing"
    );
}

#[test]
fn release_from_store_missing_signed_chain_when_no_event_log_present() {
    let tmp = TempDir::new().expect("tempdir");
    let out = run_in_data_dir(tmp.path(), &["release", "readiness", "--from-store"]);
    let report = parse_stderr_json(&out);
    let signed_chain = &report["evidence_input"]["signed_chain"];
    // The event log doesn't exist in a fresh tempdir; the detail must
    // surface that, not a stale "no key supplied" message.
    let detail = signed_chain["detail"].as_str().unwrap_or("");
    assert!(
        detail.contains("event log") || detail.contains("events.jsonl"),
        "expected detail to name missing event log, got: {detail}"
    );
}

// =============================================================================
// `compliance evidence --from-store`
// =============================================================================

#[test]
fn compliance_from_store_with_no_local_state_emits_advisory_with_all_axes_missing() {
    let tmp = TempDir::new().expect("tempdir");
    let out = run_in_data_dir(tmp.path(), &["compliance", "evidence", "--from-store"]);
    assert_precondition_unmet(&out);

    let report = parse_stderr_json(&out);
    assert_eq!(report["command"], "compliance.evidence.from_store");
    assert_eq!(report["mode"], "from_store");
    assert_eq!(report["trusted_artifact_emitted"], false);
    assert_eq!(report["all_witness_axes_present"], false);

    let missing = report["missing_witness_axes"]
        .as_array()
        .expect("missing_witness_axes is array")
        .iter()
        .filter_map(Value::as_str)
        .collect::<Vec<_>>();
    assert!(missing.contains(&"signed_chain"));
    assert!(missing.contains(&"ado_build"));
    assert!(missing.contains(&"rekor"));
    assert!(missing.contains(&"ots"));
}

#[test]
fn compliance_from_store_emits_stable_invariants_for_every_missing_axis() {
    let tmp = TempDir::new().expect("tempdir");
    let out = run_in_data_dir(tmp.path(), &["compliance", "evidence", "--from-store"]);
    let report = parse_stderr_json(&out);

    assert_eq!(
        report["evidence_input"]["signed_chain"]["invariant"],
        "compliance.evidence.from_store.witness_axis_missing.signed_chain"
    );
    assert_eq!(
        report["evidence_input"]["ado_build"]["invariant"],
        "compliance.evidence.from_store.witness_axis_missing.ado_build"
    );
    assert_eq!(
        report["evidence_input"]["rekor"]["invariant"],
        "compliance.evidence.from_store.witness_axis_missing.rekor"
    );
    assert_eq!(
        report["evidence_input"]["ots"]["invariant"],
        "compliance.evidence.from_store.witness_axis_missing.ots"
    );
}

#[test]
fn compliance_default_mode_without_from_store_still_fails_closed_on_missing_input() {
    let tmp = TempDir::new().expect("tempdir");
    let out = run_in_data_dir(tmp.path(), &["compliance", "evidence"]);
    assert_precondition_unmet(&out);

    let report = parse_stderr_json(&out);
    assert_eq!(report["command"], "compliance.evidence");
    assert_eq!(report["trusted_artifact_emitted"], false);
    assert_eq!(
        report["evidence_input"]["failed_rule"],
        "compliance.evidence_input.missing"
    );
}

#[test]
fn compliance_from_store_missing_signed_chain_when_no_event_log_present() {
    let tmp = TempDir::new().expect("tempdir");
    let out = run_in_data_dir(tmp.path(), &["compliance", "evidence", "--from-store"]);
    let report = parse_stderr_json(&out);
    let signed_chain = &report["evidence_input"]["signed_chain"];
    let detail = signed_chain["detail"].as_str().unwrap_or("");
    assert!(
        detail.contains("event log") || detail.contains("events.jsonl"),
        "expected detail to name missing event log, got: {detail}"
    );
}

// =============================================================================
// Shape parity
// =============================================================================

#[test]
fn release_and_compliance_from_store_share_the_same_evidence_input_axes_keys() {
    let tmp = TempDir::new().expect("tempdir");
    let r = parse_stderr_json(&run_in_data_dir(
        tmp.path(),
        &["release", "readiness", "--from-store"],
    ));
    let c = parse_stderr_json(&run_in_data_dir(
        tmp.path(),
        &["compliance", "evidence", "--from-store"],
    ));

    let r_keys: Vec<&str> = r["evidence_input"]
        .as_object()
        .expect("release evidence_input is object")
        .keys()
        .map(String::as_str)
        .collect();
    let c_keys: Vec<&str> = c["evidence_input"]
        .as_object()
        .expect("compliance evidence_input is object")
        .keys()
        .map(String::as_str)
        .collect();
    // Both surfaces must expose all four axes plus the present flag and
    // data_dir for downstream tooling. This guards against accidental
    // wire-shape drift between the two surfaces.
    for key in [
        "signed_chain",
        "ado_build",
        "rekor",
        "ots",
        "all_witness_axes_present",
        "data_dir",
    ] {
        assert!(
            r_keys.contains(&key),
            "release missing key {key}: {r_keys:?}"
        );
        assert!(
            c_keys.contains(&key),
            "compliance missing key {key}: {c_keys:?}"
        );
    }
}