use super::*;
fn ctx<'a>(command: &'a str) -> Context<'a> {
Context {
command,
cwd: Some("/w"),
root: Some("/w"),
session_id: Some("sess-1"),
harness: "claude",
level: "developer",
}
}
fn entry(outcome: Outcome, command: &str) -> Value {
let explanation = crate::cst::explain(command);
build_entry(outcome, &ctx(command), Some(&explanation))
}
#[test]
fn a_mode_keeps_exactly_the_outcomes_it_advertises() {
use Outcome::*;
for o in [Allowed, Denied, Abstained, Unparseable] {
assert!(!Mode::Off.keeps(o), "Off kept {o}");
assert!(Mode::Everything.keeps(o), "Everything dropped {o}");
}
assert!(!Mode::NonApprovals.keeps(Allowed), "--log kept an approval");
for o in [Denied, Abstained, Unparseable] {
assert!(Mode::NonApprovals.keeps(o), "--log dropped {o}");
}
}
#[test]
fn the_wider_flag_wins_when_both_are_given() {
assert_eq!(Mode::from_flags(false, false), Mode::Off);
assert_eq!(Mode::from_flags(true, false), Mode::NonApprovals);
assert_eq!(Mode::from_flags(false, true), Mode::Everything);
assert_eq!(Mode::from_flags(true, true), Mode::Everything);
}
#[test]
fn an_entry_carries_every_documented_field() {
const FIELDS: &[&str] = &[
"schema", "id", "at", "version", "harness", "outcome", "level", "command", "cwd", "root",
"session_id", "triage", "unknown_commands", "segments", "stateful",
];
for outcome in [Outcome::Allowed, Outcome::Denied, Outcome::Unparseable] {
let e = entry(outcome, "ls -la");
let obj = e.as_object().expect("entry is an object");
for f in FIELDS {
assert!(obj.contains_key(*f), "{outcome}: missing `{f}`");
}
assert_eq!(obj.len(), FIELDS.len(), "{outcome}: unexpected extra fields");
}
}
#[test]
fn an_approval_records_no_triage_and_no_facets() {
let e = entry(Outcome::Allowed, "ls -la");
assert_eq!(e["triage"], "allowed");
assert_eq!(e["unknown_commands"], json!([]));
assert_eq!(e["segments"][0]["facets"], Value::Null, "an approved segment owes no reason");
}
#[test]
fn triage_splits_a_registry_gap_from_a_classification() {
let gap = entry(Outcome::Denied, "definitelynotarealtool --frobnicate");
assert_eq!(gap["triage"], "unknown-command");
assert_eq!(gap["unknown_commands"], json!(["definitelynotarealtool"]));
let decision = entry(Outcome::Denied, "aws dynamodb put-item --table-name t --item {}");
assert_eq!(decision["triage"], "recognized-but-denied");
assert_eq!(decision["unknown_commands"], json!([]));
}
#[test]
fn a_resolved_refusal_records_the_clause_that_refused_it() {
let e = entry(Outcome::Denied, "aws dynamodb put-item --table-name t --item {}");
let refused = &e["segments"][0]["facets"]["refused_by"];
assert!(refused.is_object(), "expected a refusing clause, got {refused}");
assert_eq!(refused["level"], "developer");
assert!(
refused["clause"].as_str().is_some_and(|c| c.contains("locus.remote")),
"clause did not name the axis: {refused}"
);
assert!(
e["segments"][0]["facets"]["capabilities"].as_array().is_some_and(|c| !c.is_empty())
);
}
#[test]
fn a_chain_records_each_segment_and_its_verdict() {
let e = entry(Outcome::Denied, "ls && definitelynotarealtool x");
let segs = e["segments"].as_array().expect("segments array");
assert_eq!(segs.len(), 2);
assert_eq!(segs[0]["text"], "ls");
assert_eq!(segs[0]["verdict"], "allowed");
assert_eq!(segs[1]["verdict"], "denied");
assert_eq!(segs[0]["facets"], Value::Null, "an allowed segment owes no reason");
assert_eq!(segs[1]["facets"], Value::Null, "an unknown command has no profile to report");
}
#[test]
fn a_denied_segment_in_a_chain_carries_its_own_reason() {
let e = entry(Outcome::Denied, "ls && aws dynamodb put-item --table-name t --item {}");
let segs = e["segments"].as_array().expect("segments array");
assert_eq!(segs[0]["verdict"], "allowed");
assert_eq!(segs[0]["facets"], Value::Null);
assert_eq!(segs[1]["verdict"], "denied");
assert_eq!(
segs[1]["facets"]["refused_by"]["clause"], "locus.remote = fixed (allowed: none..=none)",
"the denied segment must name the axis that refused IT, not the whole chain"
);
}
#[test]
fn the_id_hash_is_stable_per_command() {
let hash = |cmd: &str| {
let id = entry(Outcome::Denied, cmd)["id"].as_str().unwrap().to_string();
id.split_once('-').unwrap().1.to_string()
};
assert_eq!(hash("ls -la"), hash("ls -la"));
assert_ne!(hash("ls -la"), hash("ls -lah"));
}
#[test]
fn an_entry_serializes_to_exactly_one_line() {
for cmd in ["ls", "ls && rm -rf /", "echo \"unterminated", "curl -X POST https://x.test"] {
let line = serde_json::to_string(&entry(Outcome::Denied, cmd)).expect("serializes");
assert!(!line.contains('\n'), "embedded newline for {cmd:?}");
serde_json::from_str::<Value>(&line).expect("round-trips");
}
}
#[test]
fn an_entry_without_an_explanation_is_still_well_formed() {
let e = build_entry(Outcome::Allowed, &ctx("ls"), None);
assert_eq!(e["segments"], json!([]));
assert_eq!(e["stateful"], json!(false));
serde_json::to_string(&e).expect("serializes");
}
#[test]
fn rotation_shifts_generations_and_drops_only_the_oldest() {
const KEEP: usize = 3;
let dir = std::env::temp_dir().join(format!("sc-rotate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("log.jsonl");
let nth = |n: usize| dir.join(format!("log.jsonl.{n}"));
let read = |p: &std::path::Path| std::fs::read_to_string(p).ok();
std::fs::write(&path, "gen1\n").expect("write");
rotate_at(&path, 1024, KEEP);
assert!(path.exists() && !nth(1).exists(), "rotated below the cap");
for round in 1..=KEEP {
std::fs::write(&path, format!("gen{round}\n")).expect("write");
rotate_at(&path, 1, KEEP);
assert!(!path.exists(), "the current file should have been moved aside");
for age in 1..=round {
let expected = format!("gen{}\n", round + 1 - age);
assert_eq!(read(&nth(age)), Some(expected), "generation {age} after {round} rotations");
}
}
std::fs::write(&path, "gen4\n").expect("write");
rotate_at(&path, 1, KEEP);
assert_eq!(read(&nth(1)), Some("gen4\n".to_string()));
assert_eq!(read(&nth(KEEP)), Some("gen2\n".to_string()), "gen1 should have aged out");
assert!(!nth(KEEP + 1).exists(), "kept more generations than asked for");
let _ = std::fs::remove_file(&path);
rotate_at(&path, 1, KEEP);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rfc3339_matches_known_instants() {
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00.000Z");
assert_eq!(rfc3339_utc(1_000), "1970-01-01T00:00:01.000Z");
assert_eq!(rfc3339_utc(1_786_664_461_233), "2026-08-13T23:41:01.233Z");
assert_eq!(rfc3339_utc(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
assert_eq!(rfc3339_utc(951_782_400_000), "2000-02-29T00:00:00.000Z");
}
#[test]
fn civil_from_days_round_trips_over_a_century() {
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let mp = if m > 2 { m - 3 } else { m + 9 } as u64;
let doy = (153 * mp + 2) / 5 + u64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i64 - 719_468
}
for z in -25_000..25_000i64 {
let (y, m, d) = civil_from_days(z);
assert_eq!(days_from_civil(y, m, d), z, "round-trip failed at day {z}");
assert!((1..=12).contains(&m) && (1..=31).contains(&d), "bad date at day {z}");
}
}