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