Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
    );
}