use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use secunit_core::evidence::hasher::sha256_file;
use secunit_core::evidence::manifest::{
PrepareContext, RunOutcome, RunResult, SystemOutcome, SystemResult,
};
use secunit_core::evidence::runner::{self, PrepareOpts};
use secunit_core::evidence::verifier::{self, FailureKind};
use secunit_core::registry::coverage::{self, PeriodStatus};
use secunit_core::registry::loader;
use secunit_core::SCHEMA_VERSION;
use tempfile::TempDir;
use walkdir::WalkDir;
const CONTROL: &str = "sca-weekly-dependency-scan";
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata/orgs/multi-system")
.canonicalize()
.expect("fixture must exist")
}
fn staged_fixture() -> (TempDir, PathBuf) {
let src = fixture_root();
let tmp = tempfile::tempdir().expect("tempdir");
let dst = tmp.path().to_path_buf();
copy_tree(&src, &dst);
git_init_and_commit(&dst);
(tmp, dst)
}
fn git_init_and_commit(root: &Path) {
use std::process::Command;
let identity_email = format!("test{at}local.invalid", at = "@");
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(root)
.args(args)
.status()
.expect("git in PATH");
assert!(status.success(), "git {args:?} failed");
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", &identity_email]);
run(&["config", "user.name", "test"]);
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "fixture import"]);
}
fn copy_tree(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in WalkDir::new(src) {
let entry = entry.unwrap();
let rel = entry.path().strip_prefix(src).unwrap();
let target = dst.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target).unwrap();
} else if entry.file_type().is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::copy(entry.path(), &target).unwrap();
}
}
}
fn run_one(root: &Path, today: NaiveDate) -> (PrepareContext, PathBuf) {
run_one_with_status(root, today, RunOutcome::Complete, SystemOutcome::Complete)
}
fn run_one_with_status(
root: &Path,
today: NaiveDate,
run_status: RunOutcome,
system_status: SystemOutcome,
) -> (PrepareContext, PathBuf) {
run_control(root, CONTROL, today, run_status, system_status)
}
fn run_control(
root: &Path,
control: &str,
today: NaiveDate,
run_status: RunOutcome,
system_status: SystemOutcome,
) -> (PrepareContext, PathBuf) {
let (reg, report) = loader::load(root);
assert!(report.errors.is_empty(), "load errors: {:?}", report.errors);
let opts = PrepareOpts {
today: Some(today),
..Default::default()
};
let ctx = runner::prepare(®, control, &opts).expect("prepare");
for sys in &ctx.resolved_scope {
let raw = ctx.run_dir.join("by-system").join(&sys.name).join("raw");
fs::write(
raw.join("scan.json"),
format!("{{\"system\":\"{}\"}}", sys.name),
)
.unwrap();
}
fs::write(ctx.run_dir.join("findings.md"), b"# findings\nnone\n").unwrap();
let result = RunResult {
schema_version: SCHEMA_VERSION,
control_id: ctx.control_id.clone(),
run_id: ctx.run_id.clone(),
status: run_status,
by_system: ctx
.resolved_scope
.iter()
.map(|s| SystemResult {
name: s.name.clone(),
status: system_status,
note: None,
})
.collect(),
draft_risks: Vec::new(),
draft_issues: Vec::new(),
external_links: Vec::new(),
};
let result_bytes = serde_json::to_vec_pretty(&result).unwrap();
fs::write(ctx.run_dir.join("result.json"), &result_bytes).unwrap();
let completed_at = today.and_hms_opt(12, 0, 0).unwrap().and_utc();
let manifest = runner::finalize_at(®, &ctx.run_dir, completed_at).expect("finalize");
let manifest_path = ctx.run_dir.join("manifest.json");
assert_eq!(manifest.run_id, ctx.run_id);
(ctx, manifest_path)
}
#[test]
fn round_trip_three_runs_chain_intact() {
let (_tmp, root) = staged_fixture();
let dates = [
NaiveDate::from_ymd_opt(2026, 5, 4).unwrap(),
NaiveDate::from_ymd_opt(2026, 5, 11).unwrap(),
NaiveDate::from_ymd_opt(2026, 5, 18).unwrap(),
];
let expected_periods = ["2026-W19", "2026-W20", "2026-W21"];
let mut manifests: Vec<PathBuf> = Vec::new();
for d in dates {
let (_ctx, mpath) = run_one(&root, d);
manifests.push(mpath);
}
let mut prior_sha: Option<String> = None;
for (i, mp) in manifests.iter().enumerate() {
let bytes = fs::read(mp).unwrap();
let m: secunit_core::evidence::manifest::Manifest = serde_json::from_slice(&bytes).unwrap();
if i == 0 {
assert!(m.prior_run.is_none(), "first run must not link a prior");
} else {
let link = m.prior_run.as_ref().expect("prior_run set");
assert_eq!(link.manifest_sha256, prior_sha.as_deref().unwrap());
}
assert_eq!(
m.period_id.as_deref(),
Some(expected_periods[i]),
"run {i} should claim period {}",
expected_periods[i]
);
prior_sha = Some(sha256_file(mp).unwrap());
}
let report = verifier::verify(&root, Some(CONTROL)).expect("verify");
assert!(
report.is_clean(),
"expected clean verify, got failures: {:?}",
report.failures
);
assert_eq!(report.verified.len(), 3, "should verify 3 runs");
}
#[test]
fn tamper_artifact_breaks_verify() {
let (_tmp, root) = staged_fixture();
let date = NaiveDate::from_ymd_opt(2026, 5, 4).unwrap();
let (ctx, _mpath) = run_one(&root, date);
let sys = &ctx.resolved_scope[0].name;
let target = ctx
.run_dir
.join("by-system")
.join(sys)
.join("raw")
.join("scan.json");
let mut existing = fs::read(&target).unwrap();
existing.push(b'X');
fs::write(&target, &existing).unwrap();
let report = verifier::verify(&root, Some(CONTROL)).expect("verify");
let failure = report
.failures
.iter()
.find(|f| f.run_id == ctx.run_id && f.kind == FailureKind::ArtifactMismatch)
.expect("expected ArtifactMismatch failure");
assert!(
failure.detail.contains("scan.json"),
"detail should mention tampered artifact: {}",
failure.detail
);
}
#[test]
fn tamper_prior_manifest_breaks_chain() {
let (_tmp, root) = staged_fixture();
let d1 = NaiveDate::from_ymd_opt(2026, 5, 4).unwrap();
let d2 = NaiveDate::from_ymd_opt(2026, 5, 11).unwrap();
let (ctx1, _m1) = run_one(&root, d1);
let (ctx2, _m2) = run_one(&root, d2);
let m1 = ctx1.run_dir.join("manifest.json");
let mut bytes = fs::read(&m1).unwrap();
let mut f = OpenOptions::new().append(true).open(&m1).unwrap();
f.write_all(b"\n").unwrap();
drop(f);
bytes.push(b'\n');
let report = verifier::verify(&root, Some(CONTROL)).expect("verify");
let failure = report
.failures
.iter()
.find(|f| f.run_id == ctx2.run_id && f.kind == FailureKind::BrokenChain)
.expect("expected BrokenChain failure on run 2");
assert!(
failure.detail.contains(&ctx1.run_id),
"detail: {}",
failure.detail
);
}
#[test]
fn abort_seals_failed_manifest_and_clears_sentinel() {
let (_tmp, root) = staged_fixture();
let (reg, report) = loader::load(&root);
assert!(report.errors.is_empty());
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 4).unwrap()),
..Default::default()
};
let ctx = runner::prepare(®, CONTROL, &opts).unwrap();
assert!(ctx.run_dir.join(".run-pending").exists());
let manifest = runner::abort(®, &ctx.run_dir, "operator-cancelled").expect("abort");
assert_eq!(manifest.run_id, ctx.run_id);
assert_eq!(
manifest.failure_reason.as_deref(),
Some("operator-cancelled")
);
let manifest_path = ctx.run_dir.join("manifest.json");
assert!(manifest_path.exists(), "manifest.json should be written");
let parsed: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
assert_eq!(parsed["status"], "failed");
assert_eq!(parsed["failure_reason"], "operator-cancelled");
assert!(
parsed["artifacts"].as_array().unwrap().is_empty(),
"aborted run records no artifacts"
);
assert!(
!ctx.run_dir.join("abort.json").exists(),
"no abort.json sidecar after the cleanup"
);
assert!(
!ctx.run_dir.join(".run-pending").exists(),
"sentinel should be cleared"
);
assert!(
ctx.run_dir.join("prepare.json").exists(),
"prepare.json should remain"
);
}
#[test]
fn resume_returns_same_prepare_context() {
let (_tmp, root) = staged_fixture();
let (reg, _) = loader::load(&root);
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 4).unwrap()),
..Default::default()
};
let original = runner::prepare(®, CONTROL, &opts).unwrap();
let resumed = runner::resume(&original.run_dir).expect("resume");
let a = serde_json::to_string(&original).unwrap();
let b = serde_json::to_string(&resumed).unwrap();
assert_eq!(a, b, "resume must return the originally-prepared context");
}
#[test]
fn prepare_refuses_concurrent_pending_run() {
let (_tmp, root) = staged_fixture();
let (reg, _) = loader::load(&root);
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 4).unwrap()),
..Default::default()
};
let first = runner::prepare(®, CONTROL, &opts).expect("first prepare");
let second = runner::prepare(®, CONTROL, &opts);
assert!(
second.is_err(),
"expected second prepare to fail while first is pending"
);
for sys in &first.resolved_scope {
let raw = first.run_dir.join("by-system").join(&sys.name).join("raw");
fs::write(raw.join("scan.json"), b"{}").unwrap();
}
fs::write(first.run_dir.join("findings.md"), b"# none\n").unwrap();
let result = RunResult {
schema_version: SCHEMA_VERSION,
control_id: first.control_id.clone(),
run_id: first.run_id.clone(),
status: RunOutcome::Complete,
by_system: first
.resolved_scope
.iter()
.map(|s| SystemResult {
name: s.name.clone(),
status: SystemOutcome::Complete,
note: None,
})
.collect(),
draft_risks: Vec::new(),
draft_issues: Vec::new(),
external_links: Vec::new(),
};
fs::write(
first.run_dir.join("result.json"),
serde_json::to_vec_pretty(&result).unwrap(),
)
.unwrap();
runner::finalize(®, &first.run_dir).expect("finalize first");
let later = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 11).unwrap()),
..Default::default()
};
runner::prepare(®, CONTROL, &later).expect("second prepare after finalize");
}
#[test]
fn finalize_populates_next_due_in_state() {
let (_tmp, root) = staged_fixture();
run_one(&root, NaiveDate::from_ymd_opt(2026, 5, 4).unwrap());
let raw = fs::read(root.join("state.json")).expect("state.json");
let state: serde_json::Value = serde_json::from_slice(&raw).unwrap();
let next = state
.pointer("/controls/sca-weekly-dependency-scan/next_due")
.expect("next_due present in state.json");
assert_eq!(next.as_str(), Some("2026-05-11"));
}
#[test]
fn finalize_does_not_rearm_an_obligation_satisfied_early() {
let (_tmp, root) = staged_fixture();
run_control(
&root,
"ac-annual-access-review",
NaiveDate::from_ymd_opt(2026, 11, 20).unwrap(),
RunOutcome::Complete,
SystemOutcome::Complete,
);
let raw = fs::read(root.join("state.json")).expect("state.json");
let state: serde_json::Value = serde_json::from_slice(&raw).unwrap();
let next = state
.pointer("/controls/ac-annual-access-review/next_due")
.expect("next_due present in state.json");
assert_eq!(next.as_str(), Some("2027-12-31"));
}
#[test]
fn singleton_scope_defaults_to_flat_layout() {
use secunit_core::evidence::manifest::ScopeLayout;
let (_tmp, root) = staged_fixture();
let (reg, _) = loader::load(&root);
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 4).unwrap()),
..Default::default()
};
let ctx = runner::prepare(®, "aa-weekly-audit-review", &opts).expect("prepare");
assert_eq!(ctx.scope_layout, ScopeLayout::Flat);
assert!(ctx.run_dir.join("raw").exists());
assert!(!ctx.run_dir.join("by-system").exists());
}
#[test]
fn empty_scope_fails_early_no_run_dir_created() {
let (_tmp, root) = staged_fixture();
let inv_path = root.join("inventory.yaml");
fs::write(
&inv_path,
"source_repos: []\ncloud_accounts: []\nsaas: []\n",
)
.unwrap();
git_init_and_commit_amend(&root);
let (reg, _) = loader::load(&root);
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 4).unwrap()),
..Default::default()
};
let err = runner::prepare(®, "sca-weekly-dependency-scan", &opts)
.expect_err("prepare must reject empty scope");
let msg = format!("{err:#}");
assert!(
msg.contains("no inventory entries match"),
"expected helpful error, got: {msg}"
);
let evidence_dir = root.join("evidence/2026/q2/sca-weekly-dependency-scan");
assert!(
!evidence_dir.exists(),
"no run dir should be allocated when prepare fails early"
);
}
fn git_init_and_commit_amend(root: &Path) {
use std::process::Command;
let identity_email = format!("test{at}local.invalid", at = "@");
let run = |args: &[&str]| {
Command::new("git")
.current_dir(root)
.args(args)
.status()
.expect("git in PATH");
};
run(&["config", "user.email", &identity_email]);
run(&["config", "user.name", "test"]);
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "amend"]);
}
#[test]
fn unreadable_artifact_does_not_abort_chain_walk() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, root) = staged_fixture();
let d1 = NaiveDate::from_ymd_opt(2026, 5, 4).unwrap();
let d2 = NaiveDate::from_ymd_opt(2026, 5, 11).unwrap();
let (ctx1, _) = run_one(&root, d1);
let (ctx2, _) = run_one(&root, d2);
let target = ctx1
.run_dir
.join("by-system")
.join(&ctx1.resolved_scope[0].name)
.join("raw")
.join("scan.json");
let mut perm = fs::metadata(&target).unwrap().permissions();
perm.set_mode(0o000);
fs::set_permissions(&target, perm).unwrap();
let report = verifier::verify(&root, Some(CONTROL)).expect("verify");
let unreadable = report
.failures
.iter()
.find(|f| f.run_id == ctx1.run_id && f.kind == FailureKind::Unreadable);
assert!(
unreadable.is_some(),
"expected Unreadable failure for run 1; got {:?}",
report.failures
);
assert!(
report.verified.iter().any(|v| v.run_id == ctx2.run_id),
"run 2 must still be verified despite run 1's I/O error; verified: {:?}",
report.verified
);
let mut perm = fs::metadata(&target).unwrap().permissions();
perm.set_mode(0o644);
fs::set_permissions(&target, perm).unwrap();
}
#[test]
fn prepare_with_period_override_claims_past_week() {
let (_tmp, root) = staged_fixture();
let (reg, _) = loader::load(&root);
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 5).unwrap()),
period_id: Some("2026-W19".to_string()),
..Default::default()
};
let ctx = runner::prepare(®, CONTROL, &opts).expect("prepare");
assert_eq!(ctx.period_id.as_deref(), Some("2026-W19"));
for sys in &ctx.resolved_scope {
let raw = ctx.run_dir.join("by-system").join(&sys.name).join("raw");
fs::write(raw.join("scan.json"), b"{}").unwrap();
}
fs::write(ctx.run_dir.join("findings.md"), b"# none\n").unwrap();
let result = RunResult {
schema_version: SCHEMA_VERSION,
control_id: ctx.control_id.clone(),
run_id: ctx.run_id.clone(),
status: RunOutcome::Complete,
by_system: ctx
.resolved_scope
.iter()
.map(|s| SystemResult {
name: s.name.clone(),
status: SystemOutcome::Complete,
note: None,
})
.collect(),
draft_risks: Vec::new(),
draft_issues: Vec::new(),
external_links: Vec::new(),
};
fs::write(
ctx.run_dir.join("result.json"),
serde_json::to_vec_pretty(&result).unwrap(),
)
.unwrap();
let manifest = runner::finalize(®, &ctx.run_dir).expect("finalize");
assert_eq!(manifest.period_id.as_deref(), Some("2026-W19"));
}
#[test]
fn prepare_without_period_id_anchors_on_today_not_next_due() {
let (_tmp, root) = staged_fixture();
let (reg, _) = loader::load(&root);
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 2).unwrap()),
..Default::default()
};
let ctx = runner::prepare(®, CONTROL, &opts).expect("prepare");
assert_eq!(ctx.period_id.as_deref(), Some("2026-W18"));
}
#[test]
fn coverage_buckets_periods_after_two_runs() {
let (_tmp, root) = staged_fixture();
run_one(&root, NaiveDate::from_ymd_opt(2026, 5, 4).unwrap());
run_one(&root, NaiveDate::from_ymd_opt(2026, 5, 11).unwrap());
let (reg, _) = loader::load(&root);
let report = coverage::coverage(
®,
CONTROL,
NaiveDate::from_ymd_opt(2026, 4, 27).unwrap(),
NaiveDate::from_ymd_opt(2026, 5, 24).unwrap(),
NaiveDate::from_ymd_opt(2026, 5, 18).unwrap(),
)
.expect("coverage");
let by_id: std::collections::HashMap<&str, PeriodStatus> = report
.periods
.iter()
.map(|p| (p.period_id.as_str(), p.status))
.collect();
assert_eq!(by_id.get("2026-W18"), Some(&PeriodStatus::Gap));
assert_eq!(by_id.get("2026-W19"), Some(&PeriodStatus::Satisfied));
assert_eq!(by_id.get("2026-W20"), Some(&PeriodStatus::Satisfied));
assert_eq!(by_id.get("2026-W21"), Some(&PeriodStatus::Open));
let w19 = report
.periods
.iter()
.find(|p| p.period_id == "2026-W19")
.unwrap();
assert!(w19.satisfied_by.is_some());
assert!(!w19.late);
}
#[test]
fn coverage_marks_period_failed_when_only_failed_run_seals_it() {
let (_tmp, root) = staged_fixture();
run_one_with_status(
&root,
NaiveDate::from_ymd_opt(2026, 5, 4).unwrap(),
RunOutcome::Failed,
SystemOutcome::Failed,
);
let (reg, _) = loader::load(&root);
let report = coverage::coverage(
®,
CONTROL,
NaiveDate::from_ymd_opt(2026, 5, 4).unwrap(),
NaiveDate::from_ymd_opt(2026, 5, 10).unwrap(),
NaiveDate::from_ymd_opt(2026, 5, 6).unwrap(),
)
.expect("coverage");
let w19 = report
.periods
.iter()
.find(|p| p.period_id == "2026-W19")
.expect("W19 in report");
assert_eq!(w19.status, PeriodStatus::Failed);
assert!(w19.satisfied_by.is_none());
}
#[test]
fn coverage_complete_run_supersedes_earlier_failed_run() {
let (_tmp, root) = staged_fixture();
run_one_with_status(
&root,
NaiveDate::from_ymd_opt(2026, 5, 4).unwrap(),
RunOutcome::Failed,
SystemOutcome::Failed,
);
run_one_with_status(
&root,
NaiveDate::from_ymd_opt(2026, 5, 5).unwrap(),
RunOutcome::Complete,
SystemOutcome::Complete,
);
let (reg, _) = loader::load(&root);
let report = coverage::coverage(
®,
CONTROL,
NaiveDate::from_ymd_opt(2026, 5, 4).unwrap(),
NaiveDate::from_ymd_opt(2026, 5, 10).unwrap(),
NaiveDate::from_ymd_opt(2026, 5, 6).unwrap(),
)
.expect("coverage");
let w19 = report
.periods
.iter()
.find(|p| p.period_id == "2026-W19")
.expect("W19 in report");
assert_eq!(w19.status, PeriodStatus::Satisfied);
}
#[test]
fn prepare_rejects_invalid_period_no_run_dir_created() {
let (_tmp, root) = staged_fixture();
let (reg, _) = loader::load(&root);
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 4).unwrap()),
period_id: Some("not-a-period".to_string()),
..Default::default()
};
let err = runner::prepare(®, CONTROL, &opts)
.expect_err("prepare must reject an invalid period id");
let msg = format!("{err:#}");
assert!(
msg.contains("not a valid period"),
"expected validation error, got: {msg}"
);
let evidence_dir = root.join("evidence/2026/q2/sca-weekly-dependency-scan");
assert!(
!evidence_dir.exists(),
"no run dir should be allocated when prepare rejects --period"
);
}
#[test]
fn prepare_canonicalizes_operator_period_spelling() {
let (_tmp, root) = staged_fixture();
let (reg, _) = loader::load(&root);
let opts = PrepareOpts {
today: Some(NaiveDate::from_ymd_opt(2026, 5, 4).unwrap()),
period_id: Some("2026-w19".into()),
..Default::default()
};
let ctx = runner::prepare(®, CONTROL, &opts).expect("prepare");
assert_eq!(
ctx.period_id.as_deref(),
Some("2026-W19"),
"sealed period ids must be canonical or coverage can never match them"
);
}