crates/gunnar-server/tests/last_seen.rs
//! **When a person last signed in, and last changed something** (owner,
//! 2026-10-21: "as admin, when someone logged in last or did some operation —
//! under the admin users list").
//!
//! Driven through the real plane over real stores and the real gate's ledger
//! (`grpc_gate::activity_ledger`, what `serve` installs):
//! * a sign-in (a passkey login that issues a session) stamps the person's
//! last sign-in, and nothing else;
//! * an allowed call that changes something stamps the caller's last
//! operation; a read — even an admin's — does not; a refused call does not;
//! * `Access.ListPrincipals` answers both, per principal, to an admin, and
//! refuses a non-admin, who therefore reads nobody's;
//! * the stamps outlive a restart.
//!
//! The push half (a push stamps, a fetch does not) is
//! `gunnar-cli/tests/last_seen_push.rs`: it needs a real `git` over smart HTTP.
use std::sync::Arc;
use gunnar_grpc::{AccessService, Caller, ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use tonic::{Code, Request};
struct Rig {
dir: tempfile::TempDir,
stores: Arc<ServerStores>,
plane: Arc<CoreControlPlane>,
access: AccessService,
identity: Arc<gunnar_grpc::identity::GunnarIdentity>,
}
fn rig_at(dir: tempfile::TempDir, seed: bool) -> Rig {
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
let control: Arc<dyn ControlPlane> = plane.clone();
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
)
.with_audit_ledger(gunnar_server::grpc_gate::activity_ledger(
None,
Arc::clone(stores.activity()),
));
if seed {
for id in ["boss", "kim", "eve"] {
plane
.add_principal(id, id, PrincipalKind::User)
.expect("principal");
}
plane
.grant("boss", "", "", GrantLevel::Admin)
.expect("boss is a server admin");
}
let identity =
gunnar_server::identity::build(dir.path(), plane.clone(), &ServerConfig::default(), false);
let access = AccessService::new(ctx);
Rig {
dir,
stores,
plane,
access,
identity,
}
}
fn rig() -> Rig {
rig_at(tempfile::tempdir().expect("tempdir"), true)
}
fn caller(id: &str) -> Caller {
Caller {
principal_id: id.to_owned(),
fingerprint: String::new(),
machine: false,
authenticated: true,
}
}
fn authed<T>(id: &str, body: T) -> Request<T> {
let mut r = Request::new(body);
r.extensions_mut().insert(caller(id));
r
}
/// kim signs in with a passkey through the identity core (IDENTITY-HOST): a
/// login that mints a session ends in the plane's `record_passkey_use`, which
/// the SSO second factor ends in too. Returns the stamp, checked to be the
/// login's own moment.
fn kim_signs_in(r: &Rig) -> i64 {
use gunnar_grpc::ControlPlane as _;
let mut key = nornir_identity::test_host::SoftPasskey::new(3);
r.plane
.add_credential("kim", &key.id, &key.public_key(), "YubiKey", 0)
.expect("kim's passkey");
let relay = nornir_identity::Relay {
rp_id: "console.example".into(),
origin: "https://console.example".into(),
client: None,
};
let before = gunnar_core::now_ms();
let c = r.identity.login_start(&relay).expect("a login");
r.identity
.login_finish(key.login(&c, "https://console.example", true))
.expect("kim signs in");
let after = gunnar_core::now_ms();
let at = r
.stores
.activity()
.seen("kim")
.signed_in
.expect("the login stamped kim's sign-in");
assert!(
(before..=after).contains(&at),
"{before} <= {at} <= {after}"
);
at
}
async fn list_as(r: &Rig, who: &str) -> Result<ListPrincipalsResponse, tonic::Status> {
r.access
.list_principals(authed(who, ListPrincipalsRequest::default()))
.await
.map(|r| r.into_inner())
}
/// ★ A sign-in stamps the last sign-in, and is not an operation.
#[tokio::test]
async fn a_sign_in_stamps_the_last_sign_in_and_nothing_else() {
let r = rig();
let at = kim_signs_in(&r);
let seen = r.stores.activity().seen("kim");
assert_eq!(
seen.signed_in,
Some(at),
"the passkey login issued a session: {seen:?}"
);
assert_eq!(
seen.operated, None,
"a sign-in is not an operation: {seen:?}"
);
}
/// ★ A change stamps the caller's last operation; a read — even an admin's
/// listing — does not, and neither does a refused call.
#[tokio::test]
async fn a_change_stamps_the_operation_and_a_read_does_not() {
let r = rig();
r.access
.who_am_i(authed("kim", WhoAmIRequest {}))
.await
.expect("kim reads who she is");
list_as(&r, "boss").await.expect("boss lists");
assert_eq!(
r.stores.activity().seen("kim").operated,
None,
"WhoAmI is a read"
);
assert_eq!(
r.stores.activity().seen("boss").operated,
None,
"a listing is a read, admin or not"
);
let refused = list_as(&r, "eve").await.expect_err("eve is no admin");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
let refused = r
.access
.add_principal(authed(
"eve",
AddPrincipalRequest {
name: "mallory".into(),
kind: PrincipalKind::User as i32,
id: String::new(),
},
))
.await
.expect_err("eve may not add people");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert_eq!(
r.stores.activity().seen("eve").operated,
None,
"a refused call changed nothing"
);
let before = gunnar_core::now_ms();
r.access
.add_principal(authed(
"boss",
AddPrincipalRequest {
name: "newbie".into(),
kind: PrincipalKind::User as i32,
id: String::new(),
},
))
.await
.expect("boss adds a person");
let operated = r
.stores
.activity()
.seen("boss")
.operated
.expect("adding a person is an operation");
assert!(operated >= before, "{operated} < {before}");
}
/// ★ The admin's list answers both stamps per principal, for the principals it
/// lists; a principal never seen has no entry ("never"); a non-admin reads
/// none of it.
#[tokio::test]
async fn the_admin_list_answers_both_and_a_non_admin_reads_nobodys() {
let r = rig();
let signed_in = kim_signs_in(&r);
r.access
.add_principal(authed(
"boss",
AddPrincipalRequest {
name: "newbie".into(),
kind: PrincipalKind::User as i32,
id: String::new(),
},
))
.await
.expect("boss adds a person");
let list = list_as(&r, "boss").await.expect("boss lists");
assert!(list.principals.iter().any(|p| p.id == "kim"), "{list:?}");
assert_eq!(
list.last_sign_in_unix_ms.get("kim"),
Some(&signed_in),
"{list:?}"
);
assert!(list.last_operation_unix_ms.contains_key("boss"), "{list:?}");
assert!(
!list.last_operation_unix_ms.contains_key("kim"),
"kim changed nothing: {list:?}"
);
assert!(
!list.last_sign_in_unix_ms.contains_key("eve")
&& !list.last_operation_unix_ms.contains_key("eve"),
"eve was never seen: {list:?}"
);
assert!(
!list.last_sign_in_unix_ms.contains_key("boss"),
"boss never signed in: {list:?}"
);
let refused = list_as(&r, "kim").await.expect_err("kim is no admin");
assert_eq!(
refused.code(),
Code::PermissionDenied,
"kim reads nobody's stamps: {refused:?}"
);
}
/// ★ The stamps outlive a restart (flushed when the box stops).
#[tokio::test]
async fn the_stamps_outlive_a_restart() {
let r = rig();
let signed_in = kim_signs_in(&r);
r.access
.add_principal(authed(
"boss",
AddPrincipalRequest {
name: "newbie".into(),
kind: PrincipalKind::User as i32,
id: String::new(),
},
))
.await
.expect("boss adds a person");
let operated = r.stores.activity().seen("boss").operated;
let Rig {
dir,
stores,
plane,
access,
identity,
} = r;
drop((access, identity, plane, stores));
let r = rig_at(dir, false);
let list = list_as(&r, "boss")
.await
.expect("boss lists after the restart");
assert_eq!(
list.last_sign_in_unix_ms.get("kim"),
Some(&signed_in),
"{list:?}"
);
assert_eq!(
list.last_operation_unix_ms.get("boss").copied(),
operated,
"{list:?}"
);
}
/// ★ **The stamps live in the control plane's redb, not in a JSON file**
/// (owner, 2026-10-21: "why is it not redb"): they survive a restart from the
/// database, and no `principal-activity.json` is written.
#[tokio::test]
async fn the_stamps_survive_a_restart_from_redb_and_no_json_is_written() {
let r = rig();
let signed_in = kim_signs_in(&r);
r.access
.add_principal(authed(
"boss",
AddPrincipalRequest {
name: "newbie".into(),
kind: PrincipalKind::User as i32,
id: String::new(),
},
))
.await
.expect("boss adds a person");
let operated = r.stores.activity().seen("boss").operated;
let json = r.stores.control_dir().join(gunnar_core::ACTIVITY_FILE);
let Rig {
dir,
stores,
plane,
access,
identity,
} = r;
drop((access, identity, plane, stores));
assert!(
!json.exists(),
"the stamps were written to {} and not to redb",
json.display()
);
let r = rig_at(dir, false);
assert!(!json.exists(), "{} appeared on reopen", json.display());
let list = list_as(&r, "boss")
.await
.expect("boss lists after the restart");
assert_eq!(
list.last_sign_in_unix_ms.get("kim"),
Some(&signed_in),
"{list:?}"
);
assert_eq!(
list.last_operation_unix_ms.get("boss").copied(),
operated,
"{list:?}"
);
}
/// ★ **A box that kept its stamps in the old JSON file** reads it once into
/// the table, and the file is left where it was.
#[tokio::test]
async fn an_old_json_file_is_read_once_into_redb_and_left() {
let dir = tempfile::tempdir().expect("tempdir");
let control = {
let r = rig_at(dir, true);
let control = r.stores.control_dir().to_path_buf();
let Rig {
dir,
stores,
plane,
access,
identity,
} = r;
drop((access, identity, plane, stores));
(dir, control)
};
let (dir, control) = control;
let json = control.join(gunnar_core::ACTIVITY_FILE);
std::fs::write(&json, br#"{"kim":{"signed_in":1000,"operated":2000}}"#).expect("an old file");
{
let r = rig_at(dir, false);
let list = list_as(&r, "boss").await.expect("boss lists");
assert_eq!(
list.last_sign_in_unix_ms.get("kim"),
Some(&1000),
"{list:?}"
);
assert_eq!(
list.last_operation_unix_ms.get("kim"),
Some(&2000),
"{list:?}"
);
let Rig {
dir,
stores,
plane,
access,
identity,
} = r;
drop((access, identity, plane, stores));
assert!(json.exists(), "the old file is left, not deleted");
// The file says something else now: the table, not the file, answers.
std::fs::write(&json, br#"{"kim":{"signed_in":5,"operated":6}}"#).expect("rewrite");
let r = rig_at(dir, false);
let list = list_as(&r, "boss").await.expect("boss lists again");
assert_eq!(
list.last_sign_in_unix_ms.get("kim"),
Some(&1000),
"the file was read again: {list:?}"
);
}
}