use std::fs;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use secunit_core::evidence::manifest::{RunOutcome, RunResult, SystemOutcome, SystemResult};
use secunit_core::evidence::runner::{self, PrepareOpts};
use secunit_core::model::Cadence;
use secunit_core::registry::loader;
use secunit_core::reports;
use secunit_core::risks::{self, FindingRef, Severity, Status};
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) {
run_one_claiming(root, today, None);
}
fn run_one_claiming(root: &Path, today: NaiveDate, period_id: Option<&str>) {
let (reg, report) = loader::load(root);
assert!(report.errors.is_empty(), "load errors: {:?}", report.errors);
let opts = PrepareOpts {
today: Some(today),
period_id: period_id.map(str::to_string),
..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: 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 completed_at = today.and_hms_opt(12, 0, 0).unwrap().and_utc();
runner::finalize_at(®, &ctx.run_dir, completed_at).expect("finalize");
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn monthly_window_counts_runs_and_surfaces_missed_weeks() {
let (_tmp, root) = staged_fixture();
run_one(&root, d(2026, 5, 4)); run_one(&root, d(2026, 5, 11));
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 6, 1),
)
.expect("assemble");
assert_eq!(data.period.label, "2026-05");
let sca = data
.controls
.iter()
.find(|c| c.id == CONTROL)
.expect("weekly control present");
assert_eq!(sca.counts.satisfied, 2, "W19 + W20 sealed");
assert_eq!(
sca.counts.gaps, 3,
"W18, W21, W22 missed — the report must call out consecutive misses"
);
assert_eq!(sca.runs.len(), 2);
assert!(sca.runs.iter().all(|r| r.path.starts_with("evidence/")));
assert_eq!(data.totals.runs, 2);
assert!(data.totals.gaps >= 2);
let aa = data
.overdue
.iter()
.find(|o| o.id == "aa-weekly-audit-review")
.expect("stale weekly control should be overdue");
assert_eq!(aa.next_due, d(2026, 5, 4));
assert_eq!(aa.days_overdue, 28);
assert!(
!data
.upcoming
.iter()
.any(|u| u.id == "aa-weekly-audit-review"),
"an overdue control belongs in overdue, not upcoming"
);
}
#[test]
fn weekly_window_scopes_to_one_week() {
let (_tmp, root) = staged_fixture();
run_one(&root, d(2026, 5, 4));
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-W19",
Cadence::Weekly,
d(2026, 5, 4),
d(2026, 5, 10),
d(2026, 5, 11),
)
.expect("assemble");
let sca = data.controls.iter().find(|c| c.id == CONTROL).unwrap();
assert_eq!(sca.periods.len(), 1);
assert_eq!(sca.periods[0].period_id, "2026-W19");
assert_eq!(sca.counts.satisfied, 1);
assert_eq!(sca.counts.gaps, 0);
assert_eq!(sca.runs.len(), 1);
let vuln = data
.controls
.iter()
.find(|c| c.id == "ca-quarterly-vuln-scan")
.expect("quarterly control present");
assert_eq!(vuln.periods[0].period_id, "2026-q2");
}
#[test]
fn risk_register_delta_counts_events_in_window() {
let (_tmp, root) = staged_fixture();
let finding_ref = |fid: &str| FindingRef {
control_id: CONTROL.to_string(),
run_id: "2026-05-04-run-001".to_string(),
manifest_sha256: "0".repeat(64),
finding_id: fid.to_string(),
body_path: None,
};
let ts = |day: u32| d(2026, 5, day).and_hms_opt(9, 0, 0).unwrap().and_utc();
let r1 = risks::open(
&root,
finding_ref("F-001"),
"vulnerable dependency",
Severity::High,
4,
3,
vec!["api".into()],
7,
d(2026, 5, 12),
"tester",
None,
Some(ts(5)),
)
.expect("open r1");
let r2 = risks::open(
&root,
finding_ref("F-002"),
"stale access grant",
Severity::Medium,
3,
2,
vec!["api".into()],
30,
d(2026, 6, 4),
"tester",
None,
Some(ts(6)),
)
.expect("open r2");
risks::append(
&root,
&r2.risk_id,
risks::EventData::StatusChanged {
from: Status::Open,
to: Status::InProgress,
reason: "assigned".into(),
},
"tester",
None,
Some(ts(15)),
)
.expect("start r2");
risks::append(
&root,
&r2.risk_id,
risks::EventData::Remediated {
resolved_run_ref: None,
note: "revoked".into(),
},
"tester",
None,
Some(ts(20)),
)
.expect("remediate r2");
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 6, 1),
)
.expect("assemble");
assert_eq!(data.risks.opened_in_period, 2);
assert_eq!(data.risks.reopened_in_period, 0);
assert_eq!(data.risks.closed_in_period, 1);
assert_eq!(data.risks.open.len(), 1);
assert_eq!(data.risks.open[0].risk_id, r1.risk_id);
assert_eq!(data.risks.open[0].status, Status::Open);
assert!(data.risks.open[0].past_sla, "due 5-12 < today 6-1");
assert_eq!(data.risks.past_sla, 1);
assert_eq!(
data.risks.open[0].source_control.as_deref(),
Some(CONTROL),
"open risk traces back to its source control"
);
let june = reports::assemble(
®,
"2026-06",
Cadence::Monthly,
d(2026, 6, 1),
d(2026, 6, 30),
d(2026, 6, 15),
)
.expect("assemble june");
assert_eq!(june.risks.opened_in_period, 0);
assert_eq!(june.risks.closed_in_period, 0);
assert_eq!(june.risks.open.len(), 1);
}
#[test]
fn risk_delta_counts_per_risk_through_reopen_churn() {
let (_tmp, root) = staged_fixture();
let finding_ref = |fid: &str| FindingRef {
control_id: CONTROL.to_string(),
run_id: "2026-05-04-run-001".to_string(),
manifest_sha256: "0".repeat(64),
finding_id: fid.to_string(),
body_path: None,
};
let ts = |m: u32, day: u32| d(2026, m, day).and_hms_opt(9, 0, 0).unwrap().and_utc();
let start = |root: &Path, id: &str, when| {
risks::append(
root,
id,
risks::EventData::StatusChanged {
from: Status::Open,
to: Status::InProgress,
reason: "assigned".into(),
},
"tester",
None,
Some(when),
)
.expect("start");
};
let remediate = |root: &Path, id: &str, when| {
risks::append(
root,
id,
risks::EventData::Remediated {
resolved_run_ref: None,
note: "fixed".into(),
},
"tester",
None,
Some(when),
)
.expect("remediate");
};
let reopen = |root: &Path, id: &str, when| {
risks::append(
root,
id,
risks::EventData::Reopened {
reason: "regressed".into(),
},
"tester",
None,
Some(when),
)
.expect("reopen");
};
let r1 = risks::open(
&root,
finding_ref("F-010"),
"flapping control drift",
Severity::Medium,
3,
2,
vec!["api".into()],
30,
d(2026, 6, 1),
"tester",
None,
Some(ts(5, 2)),
)
.expect("open r1");
start(&root, &r1.risk_id, ts(5, 3));
remediate(&root, &r1.risk_id, ts(5, 5));
reopen(&root, &r1.risk_id, ts(5, 10));
start(&root, &r1.risk_id, ts(5, 11));
remediate(&root, &r1.risk_id, ts(5, 20));
let r2 = risks::open(
&root,
finding_ref("F-011"),
"regressed hardening",
Severity::High,
4,
3,
vec!["api".into()],
30,
d(2026, 5, 10),
"tester",
None,
Some(ts(4, 2)),
)
.expect("open r2");
start(&root, &r2.risk_id, ts(4, 3));
remediate(&root, &r2.risk_id, ts(4, 10));
reopen(&root, &r2.risk_id, ts(5, 12));
let (reg, _) = loader::load(&root);
let may = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 6, 1),
)
.expect("assemble may");
assert_eq!(may.risks.opened_in_period, 1, "only r1 was created in May");
assert_eq!(may.risks.reopened_in_period, 2, "r1 and r2 both reopened");
assert_eq!(
may.risks.closed_in_period, 1,
"r1's double close counts once; r2's April close is out of window"
);
assert_eq!(may.risks.open.len(), 1, "only r2 is open at report time");
assert_eq!(may.risks.open[0].risk_id, r2.risk_id);
let april = reports::assemble(
®,
"2026-04",
Cadence::Monthly,
d(2026, 4, 1),
d(2026, 4, 30),
d(2026, 6, 1),
)
.expect("assemble april");
assert_eq!(april.risks.opened_in_period, 1);
assert_eq!(april.risks.closed_in_period, 1, "closed as of April 30");
assert_eq!(april.risks.reopened_in_period, 0);
}
#[test]
fn catch_up_run_sealed_in_window_is_reported() {
let (_tmp, root) = staged_fixture();
run_one_claiming(&root, d(2026, 5, 5), Some("2026-W18"));
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-W19",
Cadence::Weekly,
d(2026, 5, 4),
d(2026, 5, 10),
d(2026, 5, 11),
)
.expect("assemble");
let sca = data.controls.iter().find(|c| c.id == CONTROL).unwrap();
assert_eq!(
sca.runs.len(),
1,
"a run sealed inside W19 must appear even though it claims W18"
);
assert_eq!(sca.runs[0].period_id.as_deref(), Some("2026-W18"));
assert_eq!(sca.counts.satisfied, 0);
}
#[test]
fn corrupt_risk_log_degrades_to_register_error() {
let (_tmp, root) = staged_fixture();
let finding_ref = |fid: &str| FindingRef {
control_id: CONTROL.to_string(),
run_id: "2026-05-04-run-001".to_string(),
manifest_sha256: "0".repeat(64),
finding_id: fid.to_string(),
body_path: None,
};
let ts = d(2026, 5, 5).and_hms_opt(9, 0, 0).unwrap().and_utc();
let good = risks::open(
&root,
finding_ref("F-001"),
"vulnerable dependency",
Severity::High,
4,
3,
vec!["api".into()],
30,
d(2026, 6, 4),
"tester",
None,
Some(ts),
)
.expect("open good risk");
let bad = risks::open(
&root,
finding_ref("F-002"),
"stale access grant",
Severity::Medium,
3,
2,
vec!["api".into()],
30,
d(2026, 6, 4),
"tester",
None,
Some(ts),
)
.expect("open bad risk");
let log = root.join("risks").join(&bad.risk_id).join("events.jsonl");
let text = fs::read_to_string(&log).unwrap();
fs::write(&log, &text[..text.len() / 2]).unwrap();
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 6, 1),
)
.expect("one broken log must not abort the report");
assert_eq!(data.risks.opened_in_period, 1, "good risk still counted");
assert_eq!(data.risks.open.len(), 1);
assert_eq!(data.risks.open[0].risk_id, good.risk_id);
assert_eq!(data.risks.register_errors.len(), 1);
assert_eq!(data.risks.register_errors[0].risk_id, bad.risk_id);
let err = &data.risks.register_errors[0].error;
assert!(
!err.contains(&root.display().to_string()),
"error leaks the store root: {err}"
);
assert!(
err.contains("risks/"),
"error should keep the relative path: {err}"
);
}
#[test]
fn late_assembly_looks_forward_from_today() {
let (_tmp, root) = staged_fixture();
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 7, 10),
)
.expect("assemble late");
assert_eq!(data.period.horizon, d(2026, 7, 31), "end of today's month");
assert_eq!(data.period.cadence, Cadence::Monthly);
for row in secunit_core::registry::resolver::due_rows(®, d(2026, 7, 10)) {
let Some(next_due) = row.next_due else {
continue;
};
if next_due > data.period.horizon {
continue;
}
let in_overdue = data.overdue.iter().any(|o| o.id == row.control_id);
let in_upcoming = data.upcoming.iter().any(|u| u.id == row.control_id);
assert!(
in_overdue ^ in_upcoming,
"{} (due {next_due}) must appear in exactly one of overdue/upcoming",
row.control_id
);
}
}
#[test]
fn corrupt_manifest_degrades_to_manifest_error() {
let (_tmp, root) = staged_fixture();
run_one(&root, d(2026, 5, 4)); run_one(&root, d(2026, 5, 11));
let w19_dir = root.join("evidence/2026/q2");
let run_dir = fs::read_dir(w19_dir.join(CONTROL))
.unwrap()
.map(|e| e.unwrap().path())
.min()
.unwrap();
let mpath = run_dir.join("manifest.json");
let text = fs::read_to_string(&mpath).unwrap();
fs::write(&mpath, &text[..text.len() / 2]).unwrap();
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 6, 1),
)
.expect("one corrupt manifest must not abort the report");
let sca = data.controls.iter().find(|c| c.id == CONTROL).unwrap();
assert_eq!(sca.runs.len(), 1, "W20 still counted");
assert_eq!(data.manifest_errors.len(), 1);
let err = &data.manifest_errors[0];
assert_eq!(err.control_id, CONTROL);
assert!(
err.path.starts_with("evidence/"),
"path must be store-relative: {}",
err.path
);
assert!(err.error.contains("parse manifest.json"));
}
#[test]
fn lapsed_accepted_exception_is_surfaced() {
let (_tmp, root) = staged_fixture();
let finding_ref = FindingRef {
control_id: CONTROL.to_string(),
run_id: "2026-05-04-run-001".to_string(),
manifest_sha256: "0".repeat(64),
finding_id: "F-001".to_string(),
body_path: None,
};
let ts = |day: u32| d(2026, 5, day).and_hms_opt(9, 0, 0).unwrap().and_utc();
let r = risks::open(
&root,
finding_ref,
"single-region BCP exposure",
Severity::High,
4,
3,
vec!["api".into()],
30,
d(2026, 6, 3),
"tester",
None,
Some(ts(4)),
)
.expect("open");
risks::append(
&root,
&r.risk_id,
risks::EventData::ExceptionDocumented {
rationale: "accepted for the quarter".into(),
approved_by: "cto".into(),
expires_at: d(2026, 5, 20),
},
"tester",
None,
Some(ts(5)),
)
.expect("except");
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 6, 1),
)
.expect("assemble");
assert!(data.risks.open.is_empty());
assert_eq!(data.risks.lapsed_exceptions.len(), 1);
let lapsed = &data.risks.lapsed_exceptions[0];
assert_eq!(lapsed.risk_id, r.risk_id);
assert_eq!(lapsed.expired_on, d(2026, 5, 20));
let may20 = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 5, 15),
)
.expect("assemble mid-may");
assert!(may20.risks.lapsed_exceptions.is_empty());
}
#[test]
fn orphan_risk_dir_warns_in_verify_and_stays_out_of_reports() {
use secunit_core::evidence::verifier;
let (_tmp, root) = staged_fixture();
let finding_ref = FindingRef {
control_id: CONTROL.to_string(),
run_id: "2026-05-04-run-001".to_string(),
manifest_sha256: "0".repeat(64),
finding_id: "F-001".to_string(),
body_path: None,
};
let r = risks::open(
&root,
finding_ref,
"vulnerable dependency",
Severity::High,
4,
3,
vec!["api".into()],
30,
d(2026, 6, 4),
"tester",
None,
Some(d(2026, 5, 5).and_hms_opt(9, 0, 0).unwrap().and_utc()),
)
.expect("open");
let src = root.join("risks").join(&r.risk_id);
let orphan = root.join("risks").join(format!("{}-copy", r.risk_id));
fs::create_dir_all(&orphan).unwrap();
fs::copy(src.join("events.jsonl"), orphan.join("events.jsonl")).unwrap();
let (reg, _) = loader::load(&root);
let data = reports::assemble(
®,
"2026-05",
Cadence::Monthly,
d(2026, 5, 1),
d(2026, 5, 31),
d(2026, 6, 1),
)
.expect("assemble");
assert_eq!(data.risks.open.len(), 1);
assert!(data.risks.register_errors.is_empty());
let report = verifier::verify(&root, None).expect("verify");
assert!(
report.verified_risks.iter().all(|v| v.risk_id == r.risk_id),
"only the member risk is verified"
);
assert_eq!(report.risk_warnings.len(), 1);
assert!(report.risk_warnings[0].dir.ends_with("-copy"));
assert!(report.risk_failures.iter().all(|f| f.risk_id == r.risk_id));
}