//! FC-51 — manifest verification failure event payload tests.
//!
//! `Plans/firecracker-release-readiness.md` line 110: when a Firecracker
//! cell boots with a mismatched on-disk kernel (or rootfs / VMM), the
//! supervisor MUST emit a `dev.cellos.events.cell.lifecycle.v1.manifest-failed`
//! CloudEvent carrying the role and both expected and actual SHA-256 digests
//! so taudit can answer "which artifact drifted, by how much, against which
//! manifest" without re-running the verification.
//!
//! This file pins the structural shape of that `data` payload (role +
//! lowercase-hex digest fields + manifest path) and the CloudEvent type
//! string. It does NOT exercise emission — the constructor lives in
//! cellos-core (pure structural, D11) and the host backend wires the call
//! site separately.
//!
//! FOLLOW-UP — emission point:
//!   `crates/cellos-host-firecracker/src/lib.rs::verify_artifact_digests`
//!   (~L1472; in the current tree the function is named `verify_artifacts`
//!   and lives near L1542). On a hash mismatch, that path should construct
//!   `manifest_failed_data_v1(role, expected, actual, manifest_path)`,
//!   wrap it in a `CloudEventV1` with type `LIFECYCLE_MANIFEST_FAILED_TYPE`,
//!   publish via the configured event sink, and THEN return the
//!   `CellosError::DigestMismatch` that already aborts boot. Wiring is
//!   intentionally deferred to a separate (riskier) PR so parallel waves
//!   touching `host-firecracker/src/lib.rs` do not conflict on this change.
//!
//! Doctrine notes:
//! - cellos-core MUST NOT read env vars or perform I/O (D11). This test
//!   constructs payloads from in-memory string literals only.
//! - The event payload is camelCase (matches every other v1 lifecycle
//!   payload in `events.rs`).

use cellos_core::{manifest_failed_data_v1, LIFECYCLE_MANIFEST_FAILED_TYPE};

/// 64-char lowercase hex (sha256 of the empty string) — used as a stand-in
/// for the on-disk digest in shape tests.
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";

/// 64-char lowercase hex (sha256 of "abc") — used as a stand-in for the
/// expected digest declared in the manifest.
const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";

#[test]
fn manifest_failed_payload_has_required_fields() {
    let data = manifest_failed_data_v1(
        "kernel",
        ABC_SHA256,
        EMPTY_SHA256,
        "/etc/cellos/firecracker/manifest.txt",
    )
    .expect("payload constructs");

    let obj = data.as_object().expect("payload is a JSON object");

    assert_eq!(
        obj.get("role").and_then(|v| v.as_str()),
        Some("kernel"),
        "role must be present and equal to the artifact role"
    );
    assert_eq!(
        obj.get("expectedSha256").and_then(|v| v.as_str()),
        Some(ABC_SHA256),
        "expectedSha256 must round-trip the manifest-declared digest"
    );
    assert_eq!(
        obj.get("actualSha256").and_then(|v| v.as_str()),
        Some(EMPTY_SHA256),
        "actualSha256 must round-trip the on-disk digest"
    );
    assert_eq!(
        obj.get("manifestPath").and_then(|v| v.as_str()),
        Some("/etc/cellos/firecracker/manifest.txt"),
        "manifestPath must be present so auditors can locate the manifest"
    );
}

#[test]
fn manifest_failed_digests_are_lowercase_hex_64() {
    let data =
        manifest_failed_data_v1("kernel", ABC_SHA256, EMPTY_SHA256, "/m.txt").expect("payload");
    let obj = data.as_object().expect("object");

    for field in ["expectedSha256", "actualSha256"] {
        let s = obj
            .get(field)
            .and_then(|v| v.as_str())
            .unwrap_or_else(|| panic!("missing field {field}"));
        assert_eq!(
            s.len(),
            64,
            "{field} must be 64 hex chars (got {}: {s:?})",
            s.len()
        );
        assert!(
            s.chars()
                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
            "{field} must be lowercase hex, got {s:?}"
        );
    }
}

#[test]
fn manifest_failed_type_constant_matches_urn() {
    // Pin the CloudEvent type URN. Downstream consumers (taudit, runbook
    // coverage, schema generators) match on this exact string.
    assert_eq!(
        LIFECYCLE_MANIFEST_FAILED_TYPE,
        "dev.cellos.events.cell.lifecycle.v1.manifest-failed"
    );
}

#[test]
fn manifest_failed_emits_no_extraneous_fields() {
    // Shape pin: keep the v1 payload tight. Adding fields is a v2 problem.
    let data = manifest_failed_data_v1("kernel", ABC_SHA256, EMPTY_SHA256, "/m").expect("payload");
    let obj = data.as_object().expect("object");
    let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
    keys.sort();
    assert_eq!(
        keys,
        vec!["actualSha256", "expectedSha256", "manifestPath", "role"],
        "v1 manifest-failed payload must carry exactly these four fields"
    );
}

#[test]
fn manifest_failed_supports_non_kernel_roles() {
    // The constructor is role-agnostic so the same emission point can fire
    // for rootfs / firecracker drift. FC-51 specifies kernel; the broader
    // host backend may reuse the constructor for other artifacts.
    for role in ["kernel", "rootfs", "firecracker"] {
        let data = manifest_failed_data_v1(role, ABC_SHA256, EMPTY_SHA256, "/m").expect("payload");
        assert_eq!(
            data.as_object()
                .and_then(|o| o.get("role"))
                .and_then(|v| v.as_str()),
            Some(role)
        );
    }
}
