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

nordisk/gunnar

crates/gunnar-server/tests/setting_change_audited.rs

//! ★ RA2-9: **a server setting change is in the hash-chained audit trail, before it is
//! applied** (owner decision 2026-10-26, item 5: "stored as a generic server setting,
//! audited"). Its own test binary: `tracing` caches a callsite's interest per process, and
//! `server_settings.rs` counts its audit lines with a subscriber of its own.

use std::sync::{Arc, Mutex};

use gunnar_grpc::{AdminService, Caller, ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::admin_server::Admin as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use tonic::Request;

fn caller(id: &str, machine: bool) -> Caller {
    Caller {
        principal_id: id.to_owned(),
        fingerprint: format!("SHA256:{id}"),
        machine,
        authenticated: true,
    }
}

fn authed<T>(caller: &Caller, body: T) -> Request<T> {
    let mut r = Request::new(body);
    r.extensions_mut().insert(caller.clone());
    r
}

/// ★ RA2-9: **a setting change is in the hash-chained trail, and before it is applied.** The
/// gate's own record says only that `Admin.SetSetting` was allowed; the change record names the
/// setting and old → new, it goes to the ledger the server keeps (the audit chain), and when it
/// is written the store still holds the old value. A write the store refuses is recorded as
/// refused.
#[test]
fn a_setting_change_is_recorded_in_the_audit_chain_before_it_is_applied() {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime");
    runtime.block_on(async {
        let dir = tempfile::tempdir().unwrap();
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        plane
            .add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
            .unwrap();
        plane.grant("gunnar-ui", "", "", GrantLevel::Admin).unwrap();
        let chain = Arc::new(
            gunnar_core::AuditChain::open(dir.path().join("audit").join("chain.jsonl"))
                .expect("the chain"),
        );
        let trail = gunnar_server::grpc_gate::control_audit_ledger(Arc::clone(&chain));
        // What the store held when each change record reached the ledger.
        // What each change record saw: the setting's key and the store's value then.
        type Seen = Arc<Mutex<Vec<(String, Option<Vec<u8>>)>>>;
        let seen: Seen = Arc::default();
        struct Watch {
            trail: Arc<dyn gunnar_grpc::auth::AuditLedger>,
            plane: Arc<CoreControlPlane>,
            seen: Seen,
        }
        impl gunnar_grpc::auth::AuditLedger for Watch {
            fn record(&self, record: gunnar_grpc::auth::AuditRecord<'_>) {
                if record.reason.starts_with("SETTING ") {
                    let now = self.plane.setting("console.a-switch").unwrap();
                    self.seen
                        .lock()
                        .unwrap()
                        .push((record.reason.to_owned(), now));
                }
                self.trail.record(record);
            }
        }
        let ledger: Arc<dyn gunnar_grpc::auth::AuditLedger> = Arc::new(Watch {
            trail,
            plane: Arc::clone(&plane),
            seen: Arc::clone(&seen),
        });
        let control: Arc<dyn ControlPlane> = plane;
        let admin = AdminService::new(
            ServiceContext::new(
                control,
                Arc::new(EventBus::new(64)),
                ServerConfig::default(),
            )
            .with_audit_ledger(ledger),
        );
        let console = caller("gunnar-ui", true);
        admin
            .set_setting(authed(
                &console,
                SetSettingRequest {
                    name: "console.a-switch".into(),
                    value: b"on".to_vec(),
                },
            ))
            .await
            .expect("written");
        admin
            .set_setting(authed(
                &console,
                SetSettingRequest {
                    name: "console.a-switch".into(),
                    value: b"off".to_vec(),
                },
            ))
            .await
            .expect("written");
        admin
            .set_setting(authed(
                &console,
                SetSettingRequest {
                    name: "console.a-switch".into(),
                    value: vec![b'x'; 4097],
                },
            ))
            .await
            .expect_err("too big");
        let seen = seen.lock().unwrap().clone();
        assert_eq!(
            seen.len(),
            4,
            "a record per change, and one for the refusal: {seen:#?}"
        );
        assert_eq!(
            seen[0],
            (
                "SETTING console.a-switch: (unset) -> \"on\"".to_owned(),
                None
            ),
            "recorded before it was applied"
        );
        assert_eq!(
            seen[1],
            (
                "SETTING console.a-switch: \"on\" -> \"off\"".to_owned(),
                Some(b"on".to_vec())
            ),
            "recorded before it was applied"
        );
        assert!(
            seen[2]
                .0
                .starts_with("SETTING console.a-switch: \"off\" -> sha256:"),
            "{:?}",
            seen[2]
        );
        assert!(
            seen[3].0.starts_with("SETTING console.a-switch: refused: ")
                && seen[3].0.contains("4096"),
            "{:?}",
            seen[3]
        );
        let entries = chain.entries().expect("the chain reads");
        let changes: Vec<_> = entries
            .iter()
            .filter(|e| e.method == "Admin.SetSetting" && e.reason.starts_with("SETTING "))
            .collect();
        assert_eq!(
            changes.len(),
            4,
            "the change records are in the hash-chained trail: {entries:#?}"
        );
        assert!(
            changes
                .iter()
                .all(|e| e.principal == "gunnar-ui" && e.level == "admin"),
            "{changes:#?}"
        );
        assert_eq!(chain.verify().expect("the chain verifies"), entries.len());
    });
}

/// ★ RA4-8: **a setting change whose record is not durable is refused by name, and not
/// applied.** The server's own ledger (the activity stamp over the hash-chained trail) on a
/// chain whose file can no longer be appended to: `Admin.SetSetting` answers
/// `audit.unavailable` (UNAVAILABLE) and the store still holds nothing. Before, the failed
/// append was one ERROR line ("NOT in the trail") and the change was applied anyway.
#[test]
fn a_setting_change_whose_record_cannot_be_written_is_refused_by_name_and_not_applied() {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime");
    runtime.block_on(async {
        let dir = tempfile::tempdir().unwrap();
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        plane
            .add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
            .unwrap();
        plane.grant("gunnar-ui", "", "", GrantLevel::Admin).unwrap();
        let path = dir.path().join("audit").join("chain.jsonl");
        let chain = Arc::new(gunnar_core::AuditChain::open(&path).expect("the chain"));
        let trail = gunnar_server::grpc_gate::control_audit_ledger(Arc::clone(&chain));
        let ledger =
            gunnar_server::grpc_gate::activity_ledger(Some(trail), Arc::clone(stores.activity()));
        // The chain's file goes; a directory takes its name, so no append can land.
        let _ = std::fs::remove_file(&path);
        std::fs::create_dir_all(&path).unwrap();
        let control: Arc<dyn ControlPlane> = Arc::clone(&plane) as Arc<dyn ControlPlane>;
        let admin = AdminService::new(
            ServiceContext::new(
                control,
                Arc::new(EventBus::new(64)),
                ServerConfig::default(),
            )
            .with_audit_ledger(ledger),
        );
        let console = caller("gunnar-ui", true);
        let refused = admin
            .set_setting(authed(
                &console,
                SetSettingRequest {
                    name: "console.a-switch".into(),
                    value: b"on".to_vec(),
                },
            ))
            .await
            .expect_err("a change the trail cannot hold is refused");
        assert_eq!(refused.code(), tonic::Code::Unavailable, "{refused:?}");
        assert!(
            refused.message().starts_with("audit.unavailable"),
            "refused by name: {refused:?}"
        );
        assert_eq!(
            plane.setting("console.a-switch").unwrap(),
            None,
            "and not applied"
        );
    });
}

/// ★ RA4-8: **with no trail at all, a setting change is refused too.** The server always
/// installs its activity ledger; when the audit chain would not open, that ledger has no trail,
/// so a change could only be a log line: it is refused by name instead.
#[test]
fn a_server_without_its_audit_trail_refuses_a_setting_change_by_name() {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime");
    runtime.block_on(async {
        let dir = tempfile::tempdir().unwrap();
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        plane
            .add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
            .unwrap();
        plane.grant("gunnar-ui", "", "", GrantLevel::Admin).unwrap();
        let ledger = gunnar_server::grpc_gate::activity_ledger(None, Arc::clone(stores.activity()));
        let control: Arc<dyn ControlPlane> = Arc::clone(&plane) as Arc<dyn ControlPlane>;
        let admin = AdminService::new(
            ServiceContext::new(
                control,
                Arc::new(EventBus::new(64)),
                ServerConfig::default(),
            )
            .with_audit_ledger(ledger),
        );
        let refused = admin
            .set_setting(authed(
                &caller("gunnar-ui", true),
                SetSettingRequest {
                    name: "console.a-switch".into(),
                    value: b"on".to_vec(),
                },
            ))
            .await
            .expect_err("no trail, no change");
        assert_eq!(refused.code(), tonic::Code::Unavailable, "{refused:?}");
        assert!(
            refused.message().starts_with("audit.unavailable"),
            "{refused:?}"
        );
        assert_eq!(plane.setting("console.a-switch").unwrap(), None);
    });
}