mod common;
use common::Fixture;
use gunnar_core::model::PrincipalKind;
use gunnar_core::{AccountGate, Grant, Level, Principal, Visibility};
fn principal(fx: &Fixture, id: &str) {
fx.auth
.add_principal(Principal::new(id, id, PrincipalKind::User).expect("legal id"))
.expect("add principal");
}
#[test]
fn a_gated_principal_loses_their_own_account_at_every_level() {
let fx = Fixture::new();
principal(&fx, "alice");
let repo = fx.repo("alice", "widget");
for level in [Level::Read, Level::Write, Level::Admin] {
assert!(
fx.auth.may("alice", &repo, level).expect("acl"),
"an account owner must hold {level:?} on their own repository before any gate"
);
}
fx.auth
.gate(AccountGate::administrative(
"alice",
"operator",
"non-payment",
))
.expect("gate");
for level in [Level::Read, Level::Write, Level::Admin] {
assert!(
!fx.auth.may("alice", &repo, level).expect("acl"),
"a gated principal must be refused {level:?} on their own repository; READ is not \
safe — a fetch explodes the read cache and writes an index, so a reader racing a \
purge puts bytes back behind it"
);
}
fx.auth.lift_gate("alice").expect("lift");
for level in [Level::Read, Level::Write, Level::Admin] {
assert!(
fx.auth.may("alice", &repo, level).expect("acl"),
"lifting the gate must give {level:?} back"
);
}
}
#[test]
fn a_gated_principal_loses_the_grants_they_hold_elsewhere() {
let fx = Fixture::new();
principal(&fx, "alice");
principal(&fx, "bob");
let repo = fx.repo("bob", "widget");
fx.auth
.grant(Grant::repo("alice", repo.path(), Level::Write))
.expect("grant");
assert!(fx.auth.may("alice", &repo, Level::Write).expect("acl"));
fx.auth
.gate(AccountGate::administrative("alice", "operator", "purge"))
.expect("gate");
assert!(
!fx.auth.may("alice", &repo, Level::Write).expect("acl"),
"the gate is on the principal; a grant they hold in someone else's namespace goes with \
them"
);
assert!(fx.auth.may("bob", &repo, Level::Admin).expect("acl"));
}
#[test]
fn a_gated_account_is_closed_to_everybody_not_only_to_its_owner() {
let fx = Fixture::new();
principal(&fx, "alice");
principal(&fx, "carol");
let public = fx.repo_with(
"alice",
"open",
gunnar_core::HashKind::Sha256,
Visibility::Public,
);
let private = fx.repo("alice", "closed");
fx.auth
.grant(Grant::repo("carol", private.path(), Level::Write))
.expect("grant");
assert!(fx.auth.may("carol", &public, Level::Read).expect("acl"));
assert!(fx.auth.may("carol", &private, Level::Write).expect("acl"));
fx.auth
.gate(AccountGate::administrative("alice", "operator", "purge"))
.expect("gate");
assert!(
!fx.auth.may("carol", &public, Level::Read).expect("acl"),
"a public repository in a gated account must stop being readable: a purge that let \
strangers keep cloning would be reclaiming bytes a fetch is re-exploding behind it"
);
assert!(
!fx.auth.may("carol", &private, Level::Write).expect("acl"),
"a collaborator's grant does not survive the account being gated"
);
let carols = fx.repo("carol", "hers");
assert!(fx.auth.may("carol", &carols, Level::Admin).expect("acl"));
}
#[test]
fn a_gated_principal_may_not_create_namespaces_or_administer_the_server() {
let fx = Fixture::new();
principal(&fx, "alice");
fx.auth
.grant(Grant::server("alice", Level::Admin))
.expect("grant");
assert!(fx
.auth
.may_namespace("alice", "alice", Level::Admin)
.expect("acl"));
assert!(fx.auth.may_server("alice", Level::Admin).expect("acl"));
fx.auth
.gate(AccountGate::administrative("alice", "operator", "purge"))
.expect("gate");
assert!(
!fx.auth
.may_namespace("alice", "alice", Level::Admin)
.expect("acl"),
"`repo create` asks may_namespace; a gated user must not be able to make a new \
repository in the account being emptied"
);
assert!(
!fx.auth.may_server("alice", Level::Admin).expect("acl"),
"a gated principal holding a Server/Admin grant must not be able to lift their own gate \
through the front door"
);
}
#[test]
fn a_purge_lease_expires_but_an_administrative_gate_does_not() {
let fx = Fixture::new();
principal(&fx, "alice");
let repo = fx.repo("alice", "widget");
let stale = AccountGate::purge_lease("alice", "purge-1", gunnar_core::now_ms() - 1_000);
fx.auth.gate(stale).expect("gate");
assert!(
fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"an EXPIRED purge lease must not lock an account: the process that took it is gone and \
nothing else is going to lift it"
);
assert!(
fx.auth.gate_of("alice").expect("read").is_none(),
"an expired lease must read as no gate at all, so an operator is not told the account \
is locked when it is not"
);
let live = AccountGate::purge_lease("alice", "purge-2", gunnar_core::now_ms() + 60_000);
fx.auth.gate(live).expect("gate");
assert!(!fx.auth.may("alice", &repo, Level::Read).expect("acl"));
fx.auth
.gate(AccountGate::administrative(
"alice",
"operator",
"non-payment",
))
.expect("gate");
let gate = fx.auth.gate_of("alice").expect("read").expect("gated");
assert!(
gate.expires.is_none(),
"an administrative gate must carry no deadline"
);
assert!(!fx.auth.may("alice", &repo, Level::Read).expect("acl"));
}
#[test]
fn lifting_a_lease_leaves_an_administrative_gate_that_arrived_meanwhile() {
let fx = Fixture::new();
principal(&fx, "alice");
let repo = fx.repo("alice", "widget");
fx.auth
.gate(AccountGate::purge_lease(
"alice",
"purge-7",
gunnar_core::now_ms() + 60_000,
))
.expect("gate");
fx.auth
.gate(AccountGate::administrative(
"alice",
"operator",
"non-payment",
))
.expect("gate");
let lifted = fx.auth.lift_gate_held_by("alice", "purge-7").expect("lift");
assert!(
lifted.is_none(),
"the purge held lease `purge-7`; the row now says `operator`, so there is nothing of \
the purge's to lift"
);
assert!(
!fx.auth.may("alice", &repo, Level::Read).expect("acl"),
"the operator's suspension must survive the purge finishing; a purge that cleared it \
would silently un-suspend a non-paying account"
);
let lifted = fx
.auth
.lift_gate_held_by("alice", gunnar_core::ADMINISTRATIVE_HOLDER)
.expect("lift");
assert!(lifted.is_some());
assert!(fx.auth.may("alice", &repo, Level::Read).expect("acl"));
}
#[test]
fn removing_a_principal_takes_its_gate_with_it() {
let fx = Fixture::new();
principal(&fx, "alice");
fx.auth
.gate(AccountGate::administrative("alice", "operator", "purge"))
.expect("gate");
fx.auth.remove_principal("alice").expect("remove");
assert!(
fx.auth.gate_of("alice").expect("read").is_none(),
"a removed principal's gate must go with it; a row left behind would lock out whoever \
is created under that name next"
);
principal(&fx, "alice");
let repo = fx.repo("alice", "widget");
assert!(
fx.auth.may("alice", &repo, Level::Admin).expect("acl"),
"a freshly created principal must not inherit a dead gate"
);
}
#[test]
fn the_gate_listing_shows_live_gates_only() {
let fx = Fixture::new();
principal(&fx, "alice");
principal(&fx, "bob");
principal(&fx, "carol");
assert!(fx.auth.gates().expect("list").is_empty());
fx.auth
.gate(AccountGate::administrative("alice", "operator", "one"))
.expect("gate");
fx.auth
.gate(AccountGate::purge_lease(
"bob",
"purge-3",
gunnar_core::now_ms() + 60_000,
))
.expect("gate");
fx.auth
.gate(AccountGate::purge_lease(
"carol",
"purge-4",
gunnar_core::now_ms() - 1,
))
.expect("gate");
let listed: Vec<String> = fx
.auth
.gates()
.expect("list")
.into_iter()
.map(|gate| gate.account)
.collect();
assert_eq!(
listed,
vec!["alice".to_owned(), "bob".to_owned()],
"carol's lease has expired and must not be listed as a lockout that is not in force"
);
}
#[test]
fn require_names_the_gate_rather_than_reporting_a_plain_denial() {
let fx = Fixture::new();
principal(&fx, "alice");
let repo = fx.repo("alice", "widget");
fx.auth
.gate(AccountGate::administrative(
"alice",
"operator",
"non-payment",
))
.expect("gate");
let err = fx
.auth
.require("alice", &repo, Level::Read)
.expect_err("a gated principal must be refused");
assert!(
err.is_gated(),
"a gate must be reported as a gate and not as a missing grant: {err}"
);
assert!(
!err.is_denied(),
"is_denied and is_gated must not both be true; the SSH layer routes on them: {err}"
);
let text = format!("{err}");
assert!(text.contains("alice"), "{text}");
assert!(
text.contains("non-payment"),
"the refusal must carry the reason the operator typed, or the user has nothing to act \
on: {text}"
);
}
#[test]
fn a_gate_on_an_account_closes_the_namespaces_nested_under_it() {
let fx = Fixture::new();
principal(&fx, "alice");
principal(&fx, "bob");
let nested = fx.repo("alice/team", "widget");
fx.auth
.grant(Grant::repo("bob", nested.path(), Level::Write))
.expect("grant");
assert!(fx.auth.may("bob", &nested, Level::Write).expect("acl"));
fx.auth
.gate(AccountGate::administrative("alice", "operator", "purge"))
.expect("gate");
assert!(
!fx.auth.may("bob", &nested, Level::Write).expect("acl"),
"`alice/team` lives inside alice's directory subtree and a purge of alice reclaims it, \
so gating alice must close it too"
);
assert!(
!fx.auth
.may_namespace("bob", "alice/team", Level::Admin)
.expect("acl"),
"and no new repository may be created there while the account is shut"
);
principal(&fx, "alicehacks");
let neighbour = fx.repo("alicehacks", "widget");
assert!(
fx.auth
.may("alicehacks", &neighbour, Level::Admin)
.expect("acl"),
"`alicehacks` merely starts with `alice`; a gate on one account must not close another"
);
}