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