crates/gunnar-core/tests/account_audit.rs
//! **The account admin's audit log** (owner's enterprise list, 2026-10-05):
//! every security-relevant event in an account, append-only, hash-chained,
//! kept PER ACCOUNT, a year by default.
//!
//! These drive `gunnar_core::account_audit` through its public door only: a
//! control store, appends, a query, a verify, an export. The tamper cases go
//! through the pure verifier ([`verify_entries`]) with the stored entries in
//! hand, edited the way a person with the disk would edit them.
use std::sync::Arc;
use gunnar_core::account_audit::{
to_csv, to_json, verify_entries, AccountAuditLog, AccountEvent, AuditQuery, Outcome,
DEFAULT_RETENTION_DAYS, GENESIS,
};
use gunnar_core::{ControlStore, RedbControlStore, DAY_MS};
const NOW: i64 = 1_791_000_000_000; // 2026-10-03, a fixed instant
fn store() -> Arc<dyn ControlStore> {
Arc::new(RedbControlStore::in_memory().expect("an in-memory control store"))
}
fn event<'a>(kind: &'a str, actor: &'a str, target: &'a str) -> AccountEvent<'a> {
AccountEvent {
kind,
actor,
actor_role: "admin",
target,
detail: "",
outcome: Outcome::Ok,
}
}
fn everything() -> AuditQuery {
AuditQuery {
limit: 1000,
..AuditQuery::default()
}
}
#[test]
fn a_year_is_the_default_retention() {
assert_eq!(DEFAULT_RETENTION_DAYS, 365);
let log = AccountAuditLog::new(store()).unwrap();
assert_eq!(log.retention_days(), 365);
}
#[test]
fn each_account_has_its_own_chain_and_nothing_crosses() {
let log = AccountAuditLog::new(store()).unwrap();
let a0 = log
.append("acme", event("repo.created", "acme", "acme/site"), NOW)
.unwrap();
let a1 = log
.append("acme", event("login", "bob", "passkey"), NOW + 1)
.unwrap();
let b0 = log
.append("globex", event("login", "globex", "passkey"), NOW + 2)
.unwrap();
assert_eq!(
(a0.seq, a1.seq, b0.seq),
(0, 1, 0),
"seq counts per account"
);
assert_eq!(a0.prev, GENESIS, "each account's chain starts at genesis");
assert_eq!(b0.prev, GENESIS);
assert_eq!(a1.prev, a0.hash, "each entry names the one before it");
let acme = log.query("acme", &everything()).unwrap();
assert_eq!(acme.entries.len(), 2);
assert!(acme
.entries
.iter()
.all(|e| e.target != "passkey" || e.actor == "bob"));
let globex = log.query("globex", &everything()).unwrap();
assert_eq!(
globex.entries.len(),
1,
"globex sees only its own: {globex:?}"
);
assert!(log
.query("initech", &everything())
.unwrap()
.entries
.is_empty());
assert!(log.verify("acme").unwrap().holds);
assert!(log.verify("globex").unwrap().holds);
}
#[test]
fn an_entry_moved_to_another_account_does_not_verify() {
// The account name is hashed into every entry: a line copied from one
// account's log into another's is a broken chain, not a quiet forgery.
let log = AccountAuditLog::new(store()).unwrap();
let a = log
.append("acme", event("login", "acme", "passkey"), NOW)
.unwrap();
let report = verify_entries("globex", None, &[a]);
assert!(!report.holds, "{report:?}");
}
#[test]
fn the_log_survives_a_reopen_and_keeps_its_chain() {
let backing = store();
{
let log = AccountAuditLog::new(Arc::clone(&backing)).unwrap();
for i in 0..5 {
log.append("acme", event("login", "acme", "passkey"), NOW + i)
.unwrap();
}
}
let log = AccountAuditLog::new(backing).unwrap();
let sixth = log
.append("acme", event("logout", "acme", ""), NOW + 9)
.unwrap();
assert_eq!(sixth.seq, 5);
let report = log.verify("acme").unwrap();
assert!(report.holds, "{report:?}");
assert_eq!(report.entries, 6);
assert_eq!(report.tip_hash, sixth.hash);
}
#[test]
fn an_edited_removed_or_reordered_entry_breaks_the_chain() {
let log = AccountAuditLog::new(store()).unwrap();
for (i, kind) in [
"login",
"visibility.changed",
"repo.deleted",
"member.added",
]
.iter()
.enumerate()
{
log.append("acme", event(kind, "acme", "acme/site"), NOW + i as i64)
.unwrap();
}
let mut entries = log.query("acme", &everything()).unwrap().entries;
entries.reverse(); // the query answers newest first; the chain runs oldest first
assert!(verify_entries("acme", None, &entries).holds);
// One field edited: a refused visibility change rewritten as harmless.
let mut edited = entries.clone();
edited[1].kind = "login".into();
let report = verify_entries("acme", None, &edited);
assert!(!report.holds);
assert_eq!(report.broken_at_seq, Some(1), "{report:?}");
// One entry removed: the delete of a repository nobody may know about.
let mut removed = entries.clone();
removed.remove(2);
let report = verify_entries("acme", None, &removed);
assert!(!report.holds);
assert_eq!(report.broken_at_seq, Some(3), "{report:?}");
// Two entries swapped.
let mut swapped = entries.clone();
swapped.swap(0, 1);
assert!(!verify_entries("acme", None, &swapped).holds);
// The tail cut off is not detectable from the entries alone — the tip is
// what an admin records (the console shows it) — but the tip moves.
let report = verify_entries("acme", None, &entries[..3]);
assert!(report.holds);
assert_ne!(report.tip_hash, entries[3].hash);
}
#[test]
fn the_query_is_newest_first_filtered_and_paged() {
let log = AccountAuditLog::new(store()).unwrap();
for i in 0..30 {
let kind = if i % 3 == 0 {
"visibility.changed"
} else {
"login"
};
let actor = if i % 2 == 0 { "acme" } else { "bob" };
log.append("acme", event(kind, actor, "acme/site"), NOW + i * 60_000)
.unwrap();
}
// A page of 7, newest first, then the next page from its cursor.
let first = log
.query(
"acme",
&AuditQuery {
limit: 7,
..AuditQuery::default()
},
)
.unwrap();
assert_eq!(
first.entries.iter().map(|e| e.seq).collect::<Vec<_>>(),
(23..30).rev().collect::<Vec<_>>()
);
let cursor = first.next_before_seq.expect("there is more");
let second = log
.query(
"acme",
&AuditQuery {
limit: 7,
before_seq: Some(cursor),
..AuditQuery::default()
},
)
.unwrap();
assert_eq!(second.entries.first().map(|e| e.seq), Some(22));
// A kind filter matches the family: `visibility` is every `visibility.*`.
let vis = log
.query(
"acme",
&AuditQuery {
kinds: vec!["visibility".into()],
limit: 100,
..AuditQuery::default()
},
)
.unwrap();
assert_eq!(vis.entries.len(), 10);
assert!(vis.entries.iter().all(|e| e.kind == "visibility.changed"));
assert_eq!(vis.next_before_seq, None, "the last page says so");
// Actor and time.
let bob = log
.query(
"acme",
&AuditQuery {
actor: "bob".into(),
since_unix_ms: NOW + 10 * 60_000,
until_unix_ms: NOW + 20 * 60_000,
limit: 100,
..AuditQuery::default()
},
)
.unwrap();
assert_eq!(
bob.entries.iter().map(|e| e.seq).collect::<Vec<_>>(),
vec![19, 17, 15, 13, 11]
);
// Free text, any case, over target and detail.
let text = log
.query(
"acme",
&AuditQuery {
text: "ACME/SITE".into(),
limit: 3,
..AuditQuery::default()
},
)
.unwrap();
assert_eq!(text.entries.len(), 3);
}
#[test]
fn entries_older_than_the_retention_go_and_the_chain_still_verifies() {
let log = AccountAuditLog::new(store())
.unwrap()
.with_retention_days(30);
log.append("acme", event("login", "acme", "old"), NOW - 40 * DAY_MS)
.unwrap();
log.append("acme", event("login", "acme", "old"), NOW - 35 * DAY_MS)
.unwrap();
log.append("acme", event("login", "acme", "recent"), NOW - 3 * DAY_MS)
.unwrap();
// The append that crosses the line prunes what is past it.
let today = log
.append("acme", event("login", "acme", "today"), NOW)
.unwrap();
assert_eq!(today.seq, 3, "seq never restarts");
let all = log.query("acme", &everything()).unwrap().entries;
assert_eq!(
all.iter().map(|e| e.target.as_str()).collect::<Vec<_>>(),
vec!["today", "recent"]
);
let report = log.verify("acme").unwrap();
assert!(report.holds, "{report:?}");
assert_eq!(
report.anchor_seq,
Some(1),
"the last pruned entry is the anchor"
);
assert_eq!(report.entries, 2);
}
#[test]
fn the_export_is_csv_with_a_header_and_json_that_parses() {
let log = AccountAuditLog::new(store()).unwrap();
log.append(
"acme",
AccountEvent {
kind: "repo.created",
actor: "acme",
actor_role: "admin",
target: "acme/site",
detail: "said \"hello\", then left\nfor good",
outcome: Outcome::Ok,
},
NOW,
)
.unwrap();
log.append(
"acme",
AccountEvent {
kind: "name.refused",
actor: "bob",
actor_role: "member",
// A spreadsheet must never run what an attacker typed as a name.
target: "=HYPERLINK(\"x\")",
detail: "",
outcome: Outcome::Refused,
},
NOW + 1,
)
.unwrap();
let entries = log.query("acme", &everything()).unwrap().entries;
let csv = to_csv(&entries);
let mut lines = csv.lines();
assert_eq!(
lines.next(),
Some("seq,at_utc,kind,actor,actor_role,target,detail,outcome,hash")
);
assert!(
csv.contains("\"said \"\"hello\"\", then left\nfor good\""),
"{csv}"
);
assert!(csv.contains("2026-10-03T"), "{csv}");
assert!(csv.contains(",refused,"), "{csv}");
assert!(!csv.contains(",=HYPERLINK"), "a formula is defused: {csv}");
assert!(csv.contains("'=HYPERLINK"), "{csv}");
let json = to_json(&entries);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("the JSON export parses");
assert_eq!(parsed.as_array().map(|a| a.len()), Some(2));
assert_eq!(parsed[0]["kind"], "name.refused");
assert_eq!(parsed[1]["outcome"], "ok");
}
#[test]
fn an_account_name_that_is_no_name_is_refused() {
let log = AccountAuditLog::new(store()).unwrap();
assert!(log.append("", event("login", "x", ""), NOW).is_err());
assert!(log.append("../etc", event("login", "x", ""), NOW).is_err());
assert!(
log.append("a/b", event("login", "x", ""), NOW).is_err(),
"an account is one component"
);
}