provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use super::support::{export_scope, import_scope, init_repo, write_json};
use provenance_core::SUPPORTED_SCHEMA_VERSION;

#[test]
fn forged_terminal_import_fails_without_changing_live_scope() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, None);
    let baseline = dir.path().join("baseline.json");
    export_scope(&repo, &baseline).success();
    let before = std::fs::read(&baseline).unwrap();
    let mut forged: serde_json::Value = serde_json::from_slice(&before).unwrap();
    forged["proposal_cards"] = serde_json::json!([{
        "schema_version": SUPPORTED_SCHEMA_VERSION.0, "scope_id": "default", "id": "proposal_forged",
        "proposal_key": "forged", "proposal_type": "requirement_candidate",
        "title": "Forged", "summary": "Forged terminal ingress",
        "traceability": {
            "target": {"artifact_type": "requirement", "artifact_id": "req_missing"},
            "source_ids": [], "evidence_references": [], "supporting_claim_ids": []
        },
        "promotion_state": "accepted"
    }]);
    let input = dir.path().join("forged.json");
    write_json(&input, &forged);

    import_scope(&repo, &input)
        .failure()
        .stderr(predicates::str::contains("frozen shipped-v1 fingerprint"));

    let after = dir.path().join("after.json");
    export_scope(&repo, &after).success();
    assert_eq!(std::fs::read(after).unwrap(), before);
}

#[test]
fn late_scope_validation_failure_is_atomic() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, None);
    let baseline = dir.path().join("baseline.json");
    export_scope(&repo, &baseline).success();
    let before = std::fs::read(&baseline).unwrap();
    let mut invalid: serde_json::Value = serde_json::from_slice(&before).unwrap();
    invalid["rules"] = serde_json::json!([{
        "schema_version": SUPPORTED_SCHEMA_VERSION.0, "scope_id": "default", "id": "rule_invalid",
        "statement": "A rule naming a requirement that is not there.", "status": "active",
        "severity": "high", "requirement_ids": ["req_missing"]
    }]);
    let input = dir.path().join("invalid.json");
    write_json(&input, &invalid);

    import_scope(&repo, &input).failure();

    let after = dir.path().join("after.json");
    export_scope(&repo, &after).success();
    assert_eq!(std::fs::read(after).unwrap(), before);
    let transactions = repo.join(".provenance/cache/import-transactions");
    assert!(
        !transactions.exists() || std::fs::read_dir(transactions).unwrap().next().is_none(),
        "failed import must remove its staged transaction"
    );
}

#[test]
fn missing_disposition_canonical_artifact_import_is_atomic() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, Some("reviewer"));
    let baseline = dir.path().join("baseline.json");
    export_scope(&repo, &baseline).success();
    let before = std::fs::read(&baseline).unwrap();
    let mut invalid: serde_json::Value = serde_json::from_slice(&before).unwrap();
    invalid["sources"] = serde_json::json!([{
        "schema_version": SUPPORTED_SCHEMA_VERSION.0, "scope_id": "default", "id": "source_anchor",
        "name": "Anchor", "source_type": "document"
    }]);
    invalid["proposal_cards"] = serde_json::json!([{
        "schema_version": SUPPORTED_SCHEMA_VERSION.0, "scope_id": "default", "id": "proposal_a",
        "proposal_key": "a", "proposal_type": "source_gap", "title": "A", "summary": "A",
        "traceability": {"target": {"artifact_type": "source", "artifact_id": "source_anchor"},
            "source_ids": [], "evidence_references": [], "supporting_claim_ids": []},
        "promotion_state": "proposed"
    }]);
    invalid["dispositions"] = serde_json::json!([{
        "schema_version": SUPPORTED_SCHEMA_VERSION.0, "scope_id": "default", "id": "disposition_a",
        "proposal_id": "proposal_a", "decision": "rejected", "rationale": "Reviewed",
        "actor": {"identity_type": "human", "id": "reviewer"},
        "canonical_artifact": {"artifact_type": "requirement", "artifact_id": "req_missing"}
    }]);
    let input = dir.path().join("invalid-canonical-artifact.json");
    write_json(&input, &invalid);

    import_scope(&repo, &input)
        .failure()
        .stderr(predicates::str::contains(
            "canonical artifact does not exist",
        ));

    let after = dir.path().join("after.json");
    export_scope(&repo, &after).success();
    assert_eq!(std::fs::read(after).unwrap(), before);
}

#[test]
fn misfiled_disposition_canonical_artifact_import_is_atomic() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, Some("reviewer"));
    let baseline = dir.path().join("baseline.json");
    export_scope(&repo, &baseline).success();
    let before = std::fs::read(&baseline).unwrap();
    let mut invalid: serde_json::Value = serde_json::from_slice(&before).unwrap();
    invalid["requirements"] = serde_json::json!([{
        "schema_version": SUPPORTED_SCHEMA_VERSION.0, "scope_id": "other", "id": "req_misfiled",
        "statement": "Misfiled", "status": "active"
    }]);
    invalid["proposal_cards"] = serde_json::json!([{
        "schema_version": SUPPORTED_SCHEMA_VERSION.0, "scope_id": "default", "id": "proposal_a",
        "proposal_key": "a", "proposal_type": "requirement_candidate", "title": "A", "summary": "A",
        "traceability": {"target": {"artifact_type": "requirement", "artifact_id": "req_misfiled"},
            "source_ids": [], "evidence_references": [], "supporting_claim_ids": []},
        "promotion_state": "proposed"
    }]);
    invalid["dispositions"] = serde_json::json!([{
        "schema_version": SUPPORTED_SCHEMA_VERSION.0, "scope_id": "default", "id": "disposition_a",
        "proposal_id": "proposal_a", "decision": "rejected", "rationale": "Reviewed",
        "actor": {"identity_type": "human", "id": "reviewer"},
        "canonical_artifact": {"artifact_type": "requirement", "artifact_id": "req_misfiled"}
    }]);
    let input = dir.path().join("misfiled-canonical-artifact.json");
    write_json(&input, &invalid);

    import_scope(&repo, &input).failure();

    let after = dir.path().join("after.json");
    export_scope(&repo, &after).success();
    assert_eq!(std::fs::read(after).unwrap(), before);
}

#[test]
fn check_rejects_a_misfiled_disposition_target() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, Some("reviewer"));
    let scope = repo.join(".provenance/state/scopes/default");
    std::fs::create_dir_all(scope.join("requirements")).unwrap();
    std::fs::create_dir_all(scope.join("ideation")).unwrap();
    std::fs::write(
        scope.join("requirements/req.jsonl"),
        format!("{{\"schema_version\":{},\"scope_id\":\"other\",\"id\":\"req_misfiled\",\"statement\":\"Misfiled\",\"status\":\"active\"}}\n", SUPPORTED_SCHEMA_VERSION.0),
    )
    .unwrap();
    std::fs::write(
        scope.join("ideation/proposal_cards.jsonl"),
        format!("{{\"schema_version\":{},\"scope_id\":\"default\",\"id\":\"proposal_a\",\"proposal_key\":\"a\",\"proposal_type\":\"requirement_candidate\",\"title\":\"A\",\"summary\":\"A\",\"traceability\":{{\"target\":{{\"artifact_type\":\"requirement\",\"artifact_id\":\"req_misfiled\"}},\"source_ids\":[],\"evidence_references\":[],\"supporting_claim_ids\":[]}},\"promotion_state\":\"proposed\"}}\n", SUPPORTED_SCHEMA_VERSION.0),
    )
    .unwrap();
    std::fs::write(
        scope.join("ideation/dispositions.jsonl"),
        format!("{{\"schema_version\":{},\"scope_id\":\"default\",\"id\":\"disposition_a\",\"proposal_id\":\"proposal_a\",\"decision\":\"rejected\",\"rationale\":\"Reviewed\",\"actor\":{{\"identity_type\":\"human\",\"id\":\"reviewer\"}},\"canonical_artifact\":{{\"artifact_type\":\"requirement\",\"artifact_id\":\"req_misfiled\"}}}}\n", SUPPORTED_SCHEMA_VERSION.0),
    )
    .unwrap();

    super::support::provenance()
        .args(["check", "--repo", repo.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicates::str::contains(
            "canonical artifact does not exist",
        ));
}

#[test]
fn concurrent_first_imports_share_pristine_transaction_directory_setup() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, None);
    let input = dir.path().join("input.json");
    export_scope(&repo, &input).success();
    std::fs::remove_dir_all(repo.join(".provenance/cache")).unwrap();
    let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
    let imports = (0..8)
        .map(|_| {
            let barrier = barrier.clone();
            let repo = repo.clone();
            let input = input.clone();
            std::thread::spawn(move || {
                barrier.wait();
                std::process::Command::new(assert_cmd::cargo::cargo_bin!("provenance"))
                    .args([
                        "import",
                        "--repo",
                        repo.to_str().unwrap(),
                        "--scope",
                        "default",
                        "--input",
                        input.to_str().unwrap(),
                        "--dry-run",
                    ])
                    .output()
                    .unwrap()
            })
        })
        .collect::<Vec<_>>();

    for import in imports {
        let output = import.join().unwrap();
        assert!(
            output.status.success(),
            "concurrent import failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
}

#[cfg(unix)]
#[test]
fn import_rejects_external_file_symlink_without_changing_live_state() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, None);
    let external = dir.path().join("secret");
    std::fs::write(&external, "do not import").unwrap();
    let link = repo.join(".provenance/state/external-link");
    std::os::unix::fs::symlink(&external, &link).unwrap();
    let input = dir.path().join("input.json");
    export_scope(&repo, &input).success();

    import_scope(&repo, &input)
        .failure()
        .stderr(predicates::str::contains("unsupported state entry"));

    let metadata = std::fs::symlink_metadata(&link).unwrap();
    assert!(metadata.file_type().is_symlink());
    assert_eq!(std::fs::read_to_string(&external).unwrap(), "do not import");
}

#[cfg(unix)]
#[test]
fn dry_run_rejects_symlinked_import_transactions_directory() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, None);
    let input = dir.path().join("input.json");
    export_scope(&repo, &input).success();
    let outside = dir.path().join("outside");
    std::fs::create_dir(&outside).unwrap();
    let transactions = repo.join(".provenance/cache/import-transactions");
    if transactions.exists() {
        std::fs::remove_dir_all(&transactions).unwrap();
    }
    std::os::unix::fs::symlink(&outside, &transactions).unwrap();

    super::support::provenance()
        .args([
            "import",
            "--repo",
            repo.to_str().unwrap(),
            "--scope",
            "default",
            "--input",
            input.to_str().unwrap(),
            "--dry-run",
        ])
        .assert()
        .failure()
        .stderr(predicates::str::contains("symlink component"));

    let outside_entries = std::fs::read_dir(outside)
        .unwrap()
        .map(|entry| entry.unwrap().file_name())
        .collect::<Vec<_>>();
    assert!(
        outside_entries.is_empty(),
        "outside writes: {outside_entries:?}"
    );
}

#[cfg(unix)]
#[test]
fn dry_run_rejects_symlinked_cache_before_locking() {
    let dir = tempfile::tempdir().unwrap();
    let repo = dir.path().join("repo");
    init_repo(&repo, None);
    let input = dir.path().join("input.json");
    export_scope(&repo, &input).success();
    let outside = dir.path().join("outside-cache");
    std::fs::create_dir(&outside).unwrap();
    let cache = repo.join(".provenance/cache");
    std::fs::remove_dir_all(&cache).unwrap();
    std::os::unix::fs::symlink(&outside, &cache).unwrap();

    super::support::provenance()
        .args([
            "import",
            "--repo",
            repo.to_str().unwrap(),
            "--scope",
            "default",
            "--input",
            input.to_str().unwrap(),
            "--dry-run",
        ])
        .assert()
        .failure()
        .stderr(predicates::str::contains("symlink component"));

    let outside_entries = std::fs::read_dir(outside)
        .unwrap()
        .map(|entry| entry.unwrap().file_name())
        .collect::<Vec<_>>();
    assert!(
        outside_entries.is_empty(),
        "outside writes: {outside_entries:?}"
    );
}