Files
kyle 2408e2c622 Add bxctl audit verify
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
2026-09-21 01:35:08 -07:00

180 lines
5.3 KiB
Rust

//! `bxctl audit verify` against the fixture logs in `crates/proto/tests/fixtures/audit/`.
//! Do not edit. The output is compared byte for byte: the owner reads it, and so do scripts.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
/// A home directory whose `audit/` is a copy of the fixture log `case`. Removed when dropped.
struct Home {
path: PathBuf,
}
impl Home {
fn with_case(case: &str) -> Home {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let name = format!("bxctl-verify-{}-{n}", std::process::id());
let path = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&path);
let audit = path.join("audit");
std::fs::create_dir_all(&audit).unwrap();
let from = format!(
"{}/../proto/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
let entry = entry.unwrap();
std::fs::copy(entry.path(), audit.join(entry.file_name())).unwrap();
}
Home { path }
}
}
impl Drop for Home {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn run(case: &str) -> (bool, String) {
let home = Home::with_case(case);
// What brokerd leaves beside the log must not be read as part of it.
std::fs::write(home.path.join("audit/.lock"), "").unwrap();
let mut out = Vec::new();
let ok = bxctl::verify::run(&home.path, &mut out).unwrap();
(ok, String::from_utf8(out).unwrap())
}
/// The hex of the hash of the last line of `file` in `case`.
fn head_of(case: &str, file: &str) -> String {
let path = format!(
"{}/../proto/tests/fixtures/audit/{case}/{file}",
env!("CARGO_MANIFEST_DIR")
);
let text = std::fs::read_to_string(path).unwrap();
proto::sha256(text.lines().last().unwrap().as_bytes())
.unwrap()
.to_hex()
}
#[test]
fn a_good_log() {
let (ok, out) = run("good");
assert!(ok);
let head = head_of("good", "2026-09-18.jsonl");
assert_eq!(
out,
format!(
"audit: ok, 10 records, head {head}\n\
pending or abandoned: approval 6\n\
running or unfinished: decision 7\n"
)
);
}
#[test]
fn everything_worth_knowing_is_listed_one_per_line() {
let (ok, out) = run("recovered-next-day");
assert!(ok);
let lines: Vec<&str> = out.lines().collect();
assert!(
lines[0].starts_with("audit: ok, 11 records, head "),
"{out}"
);
assert_eq!(
lines[1..],
[
"recovered line: 2026-09-17.jsonl:6",
"pending or abandoned: approval 7",
"running or unfinished: decision 8",
]
);
let (ok, out) = run("accepted-break-older-file");
assert!(ok);
assert!(
out.contains("\naccepted break: 2026-09-18.jsonl:6\n"),
"{out}"
);
let (ok, out) = run("clock-back");
assert!(ok);
assert!(
out.ends_with("\nclock went backwards: 2026-09-18.jsonl:6\n"),
"{out}"
);
}
/// A torn final line is what a crash, or a `brokerd` in the middle of a write, leaves. It is
/// reported and is not a failure.
#[test]
fn a_torn_tail_is_reported_and_is_ok() {
let (ok, out) = run("torn-tail");
assert!(ok);
assert!(out.starts_with("audit: ok, 10 records, head "), "{out}");
assert!(
out.ends_with(
"\ntorn final line: 2026-09-18.jsonl:6 (brokerd recovers it at its next start)\n"
),
"{out}"
);
}
#[test]
fn a_broken_chain_is_two_lines_and_false() {
let cases = [
(
"changed-byte",
"2026-09-17.jsonl:4: prev is not the hash of the line before",
),
("deleted-line", "2026-09-17.jsonl:3: seq is 3, expected 2"),
(
"cut-short",
"2026-09-17.jsonl:3: does not parse as an audit record",
),
(
"file-not-chained",
"2026-09-18.jsonl:1: does not chain from the last line of the file before",
),
(
"break-wrong-line",
"2026-09-17.jsonl:4: prev is not the hash of the line before",
),
];
for (case, first) in cases {
let (ok, out) = run(case);
assert!(!ok, "{case}");
assert_eq!(
out,
format!("{first}\nsee docs/runbook.md#audit-chain-broken\n"),
"{case}"
);
}
}
#[test]
fn an_empty_audit_directory_is_an_empty_log() {
let home = Home::with_case("good");
for entry in std::fs::read_dir(home.path.join("audit")).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
let mut out = Vec::new();
assert!(bxctl::verify::run(&home.path, &mut out).unwrap());
assert_eq!(
String::from_utf8(out).unwrap(),
"audit: ok, 0 records, head none\n"
);
}
/// A home with no audit directory is a mistake in `--home`, not a clean log.
#[test]
fn a_missing_audit_directory_is_an_error() {
let home = Home::with_case("good");
std::fs::remove_dir_all(home.path.join("audit")).unwrap();
let mut out = Vec::new();
let error = bxctl::verify::run(&home.path, &mut out).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
assert!(out.is_empty());
}