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_model.rs

//! **The owner's account model (2026-10-03), the stored facts.**
//!
//! "a user starts an account. he can choose to enroll new users=employees then
//! it becomes an enterprise account meaning its members (except admin=first
//! person) cant make repos public. default for all accounts is that emitted
//! events and namespaces are not public, such things has to be set. … what
//! about users that has an account can they become employee .. no, simply
//! because they might have repos already that would be a risk for the company."
//!
//! Nothing is bought and nothing is a flag somebody sets on an account: an
//! enterprise account is one whose admin has ENROLLED employees. What this file
//! pins is the layer under the gRPC rules — the defaults, that the settings
//! survive a restart and touch no repository, that an employee can only be a
//! NEW principal, and that "enterprise" is derived.

mod common;

use std::sync::Arc;

use common::Fixture;
use gunnar_core::{Level, Namespace, Principal, PrincipalKind, RepoCatalog, Scope, Visibility};

/// ★ **Every account, by default: its namespace is NOT public and its events
/// are NOT public.** Making either public is an explicit setting, and the
/// setting survives a restart.
#[test]
fn a_namespace_and_its_events_are_private_until_set_and_the_setting_survives_a_restart() {
    let dir = tempfile::tempdir().expect("temp dir");
    let path = dir.path().join("control.redb");
    {
        let store = gunnar_core::open_control_store(&path).expect("open");
        let catalog = RepoCatalog::new(Arc::clone(&store)).expect("catalog");
        let born = catalog.create_namespace("acme").expect("ns");
        assert!(!born.public, "a new namespace was born public");
        assert!(
            !born.events_public,
            "a new account's events were born public"
        );
        let set = catalog
            .set_namespace_public("acme", true)
            .expect("made public");
        assert!(set.public && !set.events_public, "{set:?}");
        let set = catalog
            .set_namespace_events_public("acme", true)
            .expect("events made public");
        assert!(set.public && set.events_public, "{set:?}");
    }
    let store = gunnar_core::open_control_store(&path).expect("reopen");
    let catalog = RepoCatalog::new(store).expect("catalog");
    let read = catalog.namespace("acme").expect("read").expect("there");
    assert!(
        read.public && read.events_public,
        "lost on restart: {read:?}"
    );
    let back = catalog
        .set_namespace_public("acme", false)
        .expect("private again");
    assert!(
        !back.public && back.events_public,
        "one setting moved the other: {back:?}"
    );
    let back = catalog
        .set_namespace_events_public("acme", false)
        .expect("events private again");
    assert!(!back.public && !back.events_public);

    // A namespace that is not there is refused by name, never created.
    for err in [
        catalog
            .set_namespace_public("nobody", true)
            .expect_err("no such namespace"),
        catalog
            .set_namespace_events_public("nobody", true)
            .expect_err("no such namespace"),
    ] {
        assert!(format!("{err}").contains("no such namespace"), "{err}");
    }
}

/// Making a namespace public rewrites no repository: a private repository in
/// a public namespace stays private (it is the repository's own visibility).
#[test]
fn a_public_namespace_leaves_its_private_repositories_private() {
    let fx = Fixture::new();
    let private = fx.repo("acme", "secret");
    fx.catalog
        .set_namespace_public("acme", true)
        .expect("public");
    assert_eq!(
        fx.catalog.open(&private.path()).expect("open").visibility,
        Visibility::Private
    );
}

/// **Migration-safe**: a row written before the settings existed reads as
/// private on both counts, and a default row is stored exactly as before — no
/// new keys — so nothing that hashes or compares stored rows sees a change.
#[test]
fn a_row_from_before_the_settings_reads_private_and_a_default_row_is_stored_as_before() {
    let old: Namespace =
        serde_json::from_str(r#"{"name":"acme","explode_budget_bytes":null}"#).expect("old row");
    assert!(!old.public && !old.events_public);
    let stored = serde_json::to_string(&old).expect("encode");
    assert!(
        !stored.contains("public"),
        "a default namespace's row changed shape: {stored}"
    );
    let set = Namespace {
        public: true,
        events_public: true,
        ..old
    };
    let back: Namespace =
        serde_json::from_str(&serde_json::to_string(&set).expect("encode")).expect("decode");
    assert!(back.public && back.events_public);
}

/// ★ **An employee is a NEW user, created by and bound to the account** — and
/// that is what makes the account an enterprise account: nothing is set.
#[test]
fn enrolling_a_new_employee_binds_it_to_the_account_and_makes_the_account_enterprise() {
    let fx = Fixture::new();
    fx.catalog.create_namespace("acme").expect("ns");
    assert!(
        !fx.auth.is_enterprise("acme").expect("ask"),
        "born enterprise"
    );

    let bob = Principal::new("bob", "Bob", PrincipalKind::User).expect("principal");
    let enrolled = fx.auth.enroll_employee("acme", bob).expect("enroll bob");
    assert_eq!(enrolled.id, "bob");
    assert!(enrolled.created > 0, "the table stamps the row");
    assert_eq!(
        fx.auth.employer_of("bob").expect("ask").as_deref(),
        Some("acme")
    );
    assert!(
        fx.auth.is_enterprise("acme").expect("ask"),
        "an account with an employee"
    );
    assert_eq!(
        fx.auth
            .employees_of("acme")
            .expect("list")
            .iter()
            .map(|p| p.id.as_str())
            .collect::<Vec<_>>(),
        vec!["bob"]
    );
    // A member reads the account; what it may write is granted per repository.
    let grants = fx.auth.grants(Some("bob")).expect("grants");
    assert_eq!(grants.len(), 1, "{grants:?}");
    assert_eq!(grants[0].scope, Scope::Namespace("acme".into()));
    assert_eq!(grants[0].level, Level::Read);
    let secret = fx.repo("acme", "secret");
    assert!(fx.auth.may("bob", &secret, Level::Read).expect("may"));
    assert!(!fx.auth.may("bob", &secret, Level::Write).expect("may"));
    // An employee holds no account of its own and is admin of nothing.
    assert!(!fx
        .auth
        .may_namespace("bob", "acme", Level::Admin)
        .expect("may"));
}

/// ★ **An existing principal can never become an employee** ("they might have
/// repos already, that would be a risk for the company") — by id or by name —
/// and only a person is enrolled.
#[test]
fn an_existing_principal_cannot_become_an_employee() {
    let fx = Fixture::new();
    fx.catalog.create_namespace("acme").expect("ns");
    fx.auth
        .add_principal(Principal::new("carol", "Carol", PrincipalKind::User).expect("p"))
        .expect("carol has her own account");

    let again = Principal::new("carol", "Carol two", PrincipalKind::User).expect("p");
    let err = fx
        .auth
        .enroll_employee("acme", again)
        .expect_err("existing id");
    assert!(format!("{err}").contains("carol"), "{err}");
    let by_name = Principal::new("carol2", "Carol", PrincipalKind::User).expect("p");
    fx.auth
        .enroll_employee("acme", by_name)
        .expect_err("a name another principal carries");
    assert_eq!(fx.auth.employer_of("carol").expect("ask"), None);
    assert!(!fx.auth.is_enterprise("acme").expect("ask"));

    let robot = Principal::new("robot", "robot", PrincipalKind::Machine).expect("p");
    fx.auth
        .enroll_employee("acme", robot)
        .expect_err("an employee is a person");
    let itself = Principal::new("acme", "acme", PrincipalKind::User).expect("p");
    fx.auth
        .enroll_employee("acme", itself)
        .expect_err("the account is not its own employee");
}

/// The binding goes with the principal, so a recreated id is nobody's employee.
#[test]
fn removing_an_employee_removes_the_binding() {
    let fx = Fixture::new();
    fx.catalog.create_namespace("acme").expect("ns");
    fx.auth
        .enroll_employee(
            "acme",
            Principal::new("bob", "bob", PrincipalKind::User).expect("p"),
        )
        .expect("enroll");
    fx.auth.remove_principal("bob").expect("remove");
    assert_eq!(fx.auth.employer_of("bob").expect("ask"), None);
    assert!(!fx.auth.is_enterprise("acme").expect("ask"));
    fx.auth
        .add_principal(Principal::new("bob", "bob", PrincipalKind::User).expect("p"))
        .expect("the id is free again");
    assert_eq!(
        fx.auth.employer_of("bob").expect("ask"),
        None,
        "came back as an employee"
    );
}

// ── "Add users" (owner, 2026-10-04): room and seats, as STORED ─────────────

const GIB: u64 = 1 << 30;

fn person(id: &str) -> Principal {
    Principal::new(id, id, PrincipalKind::User).expect("principal")
}

/// ★ **Room and seats are stored facts**: a named employee holds its room,
/// seats wait unnamed, a claim moves one seat's room onto the employee, and
/// all of it survives a restart. Removing a seat's employee opens the seat
/// again; the account's room is what it holds minus what is promised.
#[test]
fn room_and_seats_are_stored_survive_a_restart_and_come_back_when_an_employee_goes() {
    let dir = tempfile::tempdir().expect("temp dir");
    let path = dir.path().join("control.redb");
    {
        let store = gunnar_core::open_control_store(&path).expect("open");
        let catalog = RepoCatalog::new(Arc::clone(&store)).expect("catalog");
        let auth = gunnar_core::AuthTable::new(Arc::clone(&store)).expect("auth");
        catalog.create_namespace("acme").expect("ns");
        let cap = Some(100 * GIB);

        let eve = auth
            .enroll_with_room(
                "acme",
                Some(person("eve")),
                &gunnar_core::RoomAsk {
                    room_bytes: 10 * GIB,
                    cap,
                    ..Default::default()
                },
            )
            .expect("eve with 10 GiB");
        assert_eq!(eve.principal.as_ref().map(|p| p.id.as_str()), Some("eve"));
        assert_eq!(eve.unused_bytes, Some(100 * GIB));
        let seats = auth
            .enroll_with_room(
                "acme",
                None,
                &gunnar_core::RoomAsk {
                    room_bytes: 20 * GIB,
                    seats: 3,
                    cap,
                    order: "o-1".into(),
                    ..Default::default()
                },
            )
            .expect("3 seats × 20 GiB");
        assert_eq!((seats.seats, seats.seats_open), (3, 3));
        assert_eq!(
            seats.unused_bytes,
            Some(90 * GIB),
            "eve's 10 GiB is promised"
        );

        // 100 − 10 − 60 = 30 unused: 4 × 10 is 10 short, and nothing is written.
        let dry = auth
            .enroll_with_room(
                "acme",
                None,
                &gunnar_core::RoomAsk {
                    room_bytes: 10 * GIB,
                    seats: 4,
                    cap,
                    dry_run: true,
                    ..Default::default()
                },
            )
            .expect("a dry run");
        assert_eq!((dry.shortfall_bytes, dry.unchanged), (10 * GIB, true));
        let short = auth
            .enroll_with_room(
                "acme",
                None,
                &gunnar_core::RoomAsk {
                    room_bytes: 10 * GIB,
                    seats: 4,
                    cap,
                    ..Default::default()
                },
            )
            .expect_err("40 GiB of 30");
        assert!(
            matches!(short, gunnar_core::Error::RoomShort { .. }),
            "{short:?}"
        );
        assert!(short.to_string().contains("10 GiB short"), "{short}");
        assert_eq!(
            auth.seats_of("acme").expect("seats"),
            (3, 3),
            "a refused ask wrote seats"
        );

        let kim = auth
            .enroll_with_room(
                "acme",
                Some(person("kim")),
                &gunnar_core::RoomAsk {
                    from_seat: true,
                    ..Default::default()
                },
            )
            .expect("kim fills a seat");
        assert_eq!(kim.room_bytes, 20 * GIB);
        assert_eq!((kim.seats, kim.seats_open), (3, 2));
        // A retried order is the first grant, not three more seats.
        let again = auth
            .enroll_with_room(
                "acme",
                None,
                &gunnar_core::RoomAsk {
                    room_bytes: 20 * GIB,
                    seats: 3,
                    cap,
                    order: "o-1".into(),
                    ..Default::default()
                },
            )
            .expect("the retry");
        assert!(again.unchanged);
        assert_eq!(auth.seats_of("acme").expect("seats"), (3, 2));
    }
    let store = gunnar_core::open_control_store(&path).expect("reopen");
    let auth = gunnar_core::AuthTable::new(store).expect("auth");
    assert_eq!(
        auth.seats_of("acme").expect("seats"),
        (3, 2),
        "seats lost on restart"
    );
    assert_eq!(
        auth.room_of("kim").expect("room").map(|r| r.room_bytes),
        Some(20 * GIB)
    );
    assert_eq!(
        auth.room_of("eve").expect("room").map(|r| r.room_bytes),
        Some(10 * GIB)
    );

    auth.remove_principal("kim").expect("kim goes");
    assert_eq!(
        auth.seats_of("acme").expect("seats"),
        (3, 3),
        "kim's seat did not open again"
    );
    assert_eq!(auth.room_of("kim").expect("room"), None);
    let none = auth
        .enroll_with_room(
            "acme",
            Some(person("lou")),
            &gunnar_core::RoomAsk {
                from_seat: true,
                ..Default::default()
            },
        )
        .and_then(|_| {
            auth.enroll_with_room(
                "acme",
                Some(person("mo")),
                &gunnar_core::RoomAsk {
                    from_seat: true,
                    ..Default::default()
                },
            )
        })
        .and_then(|_| {
            auth.enroll_with_room(
                "acme",
                Some(person("ned")),
                &gunnar_core::RoomAsk {
                    from_seat: true,
                    ..Default::default()
                },
            )
        })
        .and_then(|_| {
            auth.enroll_with_room(
                "acme",
                Some(person("oz")),
                &gunnar_core::RoomAsk {
                    from_seat: true,
                    ..Default::default()
                },
            )
        })
        .expect_err("a fourth claim on three seats");
    assert!(
        matches!(none, gunnar_core::Error::NoOpenSeat(_)),
        "{none:?}"
    );
    assert!(
        auth.principal("oz").expect("ask").is_none(),
        "a refused claim created oz"
    );
}