cleanlib-cli 0.1.5

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! CLEANLIB-366 — `GET /v1/audit` field-alignment drop-detector.
//!
//! Same class of bug as CLEANLIB-348: the CLI's `AuditResponse` /
//! `AuditEntry` field names had drifted from the App's wire shape
//! (`AuditRow` in `cleanlib-audit-clickhouse`). With
//! `#[serde(default)]` on the struct, mismatched JSON keys deserialize to
//! empty strings / defaults without erroring, so drift was invisible on
//! every existing test that mocked its own fake shape.
//!
//! This test mounts a `wiremock` fixture that emits the App's *real* wire
//! shape (`{window, records, record_count, per_route, backend_status}` with
//! `AuditRow` field names) and asserts every customer-visible field on the
//! CLI-side `AuditEntry` deserialized to the expected non-default value.
//! Any future App-side rename that lands without a CLI ripple fails here
//! with a specific field pointer rather than silently emptying every cell
//! in the `cleanlib audit` output table.
//!
//! Regression guard: add one assertion per field the CLI renders
//! (`request_at`, `request_id`, `ecosystem`, `package_name`,
//! `package_version`, `policy_decision`, `reasoning`) plus the response
//! envelope carriers (`record_count`, `backend_status`, `window`,
//! `per_route`).

use cleanlib_client::transport;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

/// Full-fidelity /v1/audit mock body — mirrors the App's `AuditResponse`
/// + `AuditRow` shape from `cleanlib-app/src/verbs.rs` and
/// `cleanlib-audit-clickhouse/src/lib.rs`. Every field the CLI renders
/// carries a distinct sentinel so an accidental empty-default read is
/// immediately visible.
fn audit_wire_fixture() -> serde_json::Value {
    serde_json::json!({
        "window": {
            "since": "2026-05-22T00:00:00Z",
            "until": "2026-05-23T00:00:00Z"
        },
        "records": [{
            // request identification
            "request_id": "01936b8f-3c4a-7a12-9c00-000000000001",
            "organization_id": "org-42",
            "correlation_id": "corr-cleanlib-366",
            "customer_ip_hashed": "sha256:aaaa",
            // request shape
            "ecosystem": "npm",
            "package_name": "lodash",
            "package_version": "4.17.21",
            "variant": "default",
            "user_agent": "cleanlib-cli/0.1.4",
            // decision
            "policy_decision": "ALLOW",
            "verdict_id": "01936b8f-3c4a-7a12-9c00-0000000000aa",
            "verdict_source": "ALLOWED_NO_FINDINGS",
            "policy_rule_id_matched": "rule-42",
            "risk_acceptance_status": "NONE",
            "reasoning": "no findings above threshold",
            // catalog
            "gcs_hit": true,
            "gcs_object_path": "gs://catalog/npm/lodash/4.17.21.json",
            "bytes_served": 8192,
            // timing
            "request_at": "2026-05-22T10:00:00Z",
            "ingest_at": null,
            "gcs_at": null,
            "verdict_at": "2026-05-22T10:00:01Z",
            "policy_eval_at": "2026-05-22T10:00:02Z",
            "response_at": "2026-05-22T10:00:03Z",
            // app metadata
            "app_version": "1.2.3"
        }],
        "record_count": 1,
        "per_route": {"/v1/customer/verdicts/npm": 1},
        "backend_status": "wired"
    })
}

#[tokio::test]
async fn audit_response_deserializes_every_field_from_app_wire_shape() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .respond_with(ResponseTemplate::new(200).set_body_json(audit_wire_fixture()))
        .mount(&server)
        .await;

    let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
        .expect("build test client");
    let resp = client
        .audit(Some("2026-05-22T00:00:00Z"), Some("ALLOW"), Some("npm"))
        .await
        .expect("audit call succeeds against wire-shape fixture");

    // ── envelope ────────────────────────────────────────────────────────
    assert_eq!(resp.record_count, 1, "record_count carrier lost");
    assert_eq!(resp.backend_status, "wired", "backend_status carrier lost");
    assert_eq!(
        resp.window.since.as_deref(),
        Some("2026-05-22T00:00:00Z"),
        "window.since carrier lost"
    );
    assert_eq!(
        resp.window.until.as_deref(),
        Some("2026-05-23T00:00:00Z"),
        "window.until carrier lost"
    );
    assert_eq!(
        resp.per_route.get("/v1/customer/verdicts/npm"),
        Some(&1),
        "per_route breakdown lost"
    );

    // ── records ─────────────────────────────────────────────────────────
    assert_eq!(resp.records.len(), 1, "records array lost");
    let e = &resp.records[0];

    // Fields the `cleanlib audit` table column set surfaces. Any one of
    // these coming back empty is a silent-drop regression per CLEANLIB-366.
    assert_eq!(
        e.request_id, "01936b8f-3c4a-7a12-9c00-000000000001",
        "request_id: silent-drop regression"
    );
    assert_eq!(
        e.request_at, "2026-05-22T10:00:00Z",
        "request_at: silent-drop regression (pre-fix name was `at`)"
    );
    assert_eq!(
        e.ecosystem, "npm",
        "ecosystem: silent-drop regression"
    );
    assert_eq!(
        e.package_name, "lodash",
        "package_name: silent-drop regression (pre-fix name was `package`)"
    );
    assert_eq!(
        e.package_version, "4.17.21",
        "package_version: silent-drop regression (pre-fix name was `version`)"
    );
    assert_eq!(
        e.policy_decision, "ALLOW",
        "policy_decision: silent-drop regression (pre-fix name was `decision`)"
    );
    assert_eq!(
        e.reasoning, "no findings above threshold",
        "reasoning: silent-drop regression (pre-fix name was `reason`)"
    );

    // Fields the JSON-mode output must round-trip losslessly. Testing
    // these guards the `cleanlib audit --output json` customer surface
    // against future name drift on the App side.
    assert_eq!(e.correlation_id, "corr-cleanlib-366");
    assert_eq!(e.variant, "default");
    assert_eq!(e.verdict_id, "01936b8f-3c4a-7a12-9c00-0000000000aa");
    assert_eq!(e.verdict_source, "ALLOWED_NO_FINDINGS");
    assert_eq!(e.policy_rule_id_matched, "rule-42");
    assert_eq!(e.risk_acceptance_status, "NONE");
    assert!(e.gcs_hit, "gcs_hit: silent-drop regression");
    assert_eq!(e.verdict_at, "2026-05-22T10:00:01Z");
    assert_eq!(e.response_at, "2026-05-22T10:00:03Z");
    assert_eq!(e.app_version, "1.2.3");
}

/// Backend-not-wired path: App returns the honesty signal + empty records.
/// The CLI must decode `backend_status` so callers can distinguish "no
/// matching rows" from "audit backend offline". Pre-fix code carried a
/// `next_cursor` field that never existed on the wire — losing this signal.
#[tokio::test]
async fn audit_response_carries_not_wired_backend_status() {
    let server = MockServer::start().await;
    let body = serde_json::json!({
        "window": {"since": null, "until": null},
        "records": [],
        "record_count": 0,
        "per_route": {},
        "backend_status": "not_wired"
    });

    Mock::given(method("GET"))
        .and(path_regex(r"^/v1/audit"))
        .respond_with(ResponseTemplate::new(200).set_body_json(body))
        .mount(&server)
        .await;

    let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
        .expect("build test client");
    let resp = client
        .audit(None, None, None)
        .await
        .expect("audit call succeeds on not_wired path");

    assert!(resp.records.is_empty());
    assert_eq!(resp.record_count, 0);
    assert_eq!(
        resp.backend_status, "not_wired",
        "backend_status honesty signal lost"
    );
}