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