crates/gunnar-server/tests/clans_r2.rs
//! **Clans, round 2** (owner, 2026-10-05, answering the round-1 questions),
//! on the real plane:
//!
//! * **f** — "to dissolve, the admin first moves the clan's repos out": the
//! clan's admin offers a repository to an account; only that account's admin
//! takes it (or declines; the clan's admin withdraws). Taken, it MOVES —
//! objects, references, HEAD, LFS objects, branch protection — through the
//! write paths every push takes, so the transaction log carries it as a
//! create, a pack, a ref batch, LFS objects and a purge; the clan's room is
//! freed; the account's room must hold it; and the clan, empty, dissolves.
//! * **d** — "a member stops paying: the clan KEEPS that member's room share
//! until the member's paid period ends, then the share drops, and if the
//! clan is then over its room, the push door holds it" (metered).
//! * **h** — "LFS counts in clan room": packs and LFS objects in one room
//! (metered: the door, the LFS allowance and Usage.Read).
use std::sync::Arc;
use gunnar_core::RepoRef;
use gunnar_grpc::{
AccountsService, Caller, ClansService, ControlPlane, EventBus, ReposService, ServerConfig,
ServiceContext,
};
use gunnar_lfs::LfsStore as _;
use gunnar_proto::clans_server::Clans as _;
use gunnar_proto::repos_server::Repos as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_store::git_storage_trait::RefUpdate;
use tonic::{Code, Request};
const CLAN: &str = "fellows";
const MIB: u64 = 1 << 20;
#[cfg(feature = "metered")]
const GIB: u64 = 1 << 30;
fn person(id: &str) -> Caller {
Caller {
principal_id: id.to_owned(),
fingerprint: format!("SHA256:{id}"),
machine: false,
authenticated: true,
}
}
fn with<T>(caller: &Caller, body: T) -> Request<T> {
let mut request = Request::new(body);
request.extensions_mut().insert(caller.clone());
request
}
struct Rig {
_dir: tempfile::TempDir,
stores: Arc<ServerStores>,
plane: Arc<CoreControlPlane>,
clans: ClansService,
repos: ReposService,
#[cfg(feature = "metered")]
usage: gunnar_grpc::UsageService,
#[allow(dead_code)]
accounts: AccountsService,
}
impl Rig {
fn new() -> Rig {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
Rig::with(dir, stores, plane)
}
/// alice (the clan's admin), bob (its member), carol and dave (outsiders
/// with accounts of their own), `root` a server admin.
fn with(
dir: tempfile::TempDir,
stores: Arc<ServerStores>,
plane: Arc<CoreControlPlane>,
) -> Rig {
let control: Arc<dyn ControlPlane> = plane.clone();
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
);
for id in ["alice", "bob", "carol", "dave", "root"] {
plane
.add_principal(id, id, PrincipalKind::User)
.expect("account");
}
plane
.grant("root", "", "", GrantLevel::Admin)
.expect("root is a server admin");
Rig {
_dir: dir,
stores,
clans: ClansService::new(ctx.clone()),
repos: ReposService::new(ctx.clone()),
#[cfg(feature = "metered")]
usage: gunnar_grpc::UsageService::new(ctx.clone()),
accounts: AccountsService::new(ctx),
plane,
}
}
async fn fellows(&self, alice: u64, bob: u64) {
self.clans
.create(with(
&person("alice"),
CreateClanRequest {
name: CLAN.into(),
contribution_bytes: alice,
},
))
.await
.expect("alice makes the clan");
self.clans
.invite(with(
&person("alice"),
InviteToClanRequest {
clan: CLAN.into(),
principal_id: "bob".into(),
},
))
.await
.expect("invite bob");
self.clans
.accept(with(
&person("bob"),
AcceptClanInviteRequest {
clan: CLAN.into(),
contribution_bytes: bob,
},
))
.await
.expect("bob joins");
}
async fn get(&self, who: &str) -> Result<Clan, tonic::Status> {
self.clans
.get(with(&person(who), GetClanRequest { clan: CLAN.into() }))
.await
.map(|r| r.into_inner().clan.expect("a clan"))
}
#[cfg(feature = "metered")]
async fn give(&self, who: &str, bytes: u64) -> Result<Clan, tonic::Status> {
self.clans
.set_contribution(with(
&person(who),
SetClanContributionRequest {
clan: CLAN.into(),
contribution_bytes: bytes,
},
))
.await
.map(|r| r.into_inner().clan.expect("a clan"))
}
async fn offer(&self, who: &str, repo: &str, to: &str) -> Result<Clan, tonic::Status> {
self.clans
.offer_repo(with(
&person(who),
OfferRepoRequest {
clan: CLAN.into(),
repo: repo.into(),
account: to.into(),
},
))
.await
.map(|r| r.into_inner().clan.expect("a clan"))
}
async fn answer(
&self,
who: &str,
repo: &str,
accept: bool,
) -> Result<AnswerRepoOfferResponse, tonic::Status> {
self.clans
.answer_repo_offer(with(
&person(who),
AnswerRepoOfferRequest {
clan: CLAN.into(),
repo: repo.into(),
accept,
},
))
.await
.map(|r| r.into_inner())
}
async fn make_repo(&self, who: &str, name: &str) {
self.repos
.create(with(
&person(who),
CreateRepoRequest {
path: Some(RepoPath::new(CLAN, name)),
hash_kind: HashKind::Sha256 as i32,
visibility: Visibility::Private as i32,
..Default::default()
},
))
.await
.expect("a member makes a repository in the clan");
}
/// A commit — a README blob in a tree — on `refs/heads/main`, HEAD at it,
/// and `lfs` bytes of one LFS object: written as a push writes, through
/// the logged store. Answers the commit's hex and the LFS object's oid.
fn fill(
&self,
ns: &str,
name: &str,
readme: &[u8],
lfs: usize,
) -> (String, Option<gunnar_lfs::Oid>) {
let kind = gix_hash::Kind::Sha256;
let blob = gix_object::compute_hash(kind, gix_object::Kind::Blob, readme).expect("blob id");
let mut tree = b"100644 README\0".to_vec();
tree.extend_from_slice(blob.as_slice());
let tree_id =
gix_object::compute_hash(kind, gix_object::Kind::Tree, &tree).expect("tree id");
let commit = format!(
"tree {tree_id}\nauthor uitest <a@example.invalid> 1790000000 +0000\ncommitter uitest <a@example.invalid> 1790000000 +0000\n\nfirst\n"
)
.into_bytes();
let commit_id =
gix_object::compute_hash(kind, gix_object::Kind::Commit, &commit).expect("commit id");
let mut pb = PackBuilder::new(kind);
pb.object(gix_object::Kind::Blob, readme.to_vec());
pb.object(gix_object::Kind::Tree, tree);
pb.object(gix_object::Kind::Commit, commit);
let path = RepoRef::new(ns, name).expect("legal");
let repo = self
.stores
.catalog()
.get(&path)
.expect("read")
.expect("the repo");
let handle = self.stores.repo_store(&repo).expect("open");
handle
.serve()
.put_pack(&pb.build().expect("pack").bytes)
.expect("put_pack");
handle
.serve()
.put_refs(&[RefUpdate::set("refs/heads/main", commit_id.to_string())])
.expect("put_refs");
handle.serve().set_head("refs/heads/main").expect("HEAD");
let oid = (lfs > 0).then(|| {
let body = vec![0x4cu8; lfs];
let oid = gunnar_lfs::Oid::of(&body);
let store = gunnar_lfs::FsLfsStore::open_or_init(handle.git_dir()).expect("lfs store");
use std::io::Write as _;
let mut w = store.begin_write(&oid, body.len() as u64).expect("begin");
w.write_all(&body).expect("write");
w.commit().expect("commit");
oid
});
drop(handle);
self.stores.close_repo(&path);
(commit_id.to_string(), oid)
}
/// What `ns` stores once its stores have stopped writing beside a new
/// pack (indexes land a moment after it): three equal readings in a row,
/// 300 ms apart.
fn usage_of(&self, ns: &str) -> NamespaceUsage {
let read = || self.plane.usage(Some(ns)).expect("usage").remove(0);
let key = |u: &NamespaceUsage| (u.pack_bytes, u.lfs_bytes, u.repos);
let (mut last, mut same) = (read(), 0);
for _ in 0..100 {
std::thread::sleep(std::time::Duration::from_millis(300));
let now = read();
same = if key(&now) == key(&last) { same + 1 } else { 0 };
last = now;
if same >= 3 {
break;
}
}
last
}
/// Every transaction-log frame (kind, meta) that names `repo`.
fn frames_of(&self, repo: &str) -> Vec<(u16, String)> {
let mut out = Vec::new();
let needle = format!("\"repo\":\"{repo}\"");
self.stores
.txlog()
.scan_meta(|_, kind, meta| {
let meta = String::from_utf8_lossy(meta).into_owned();
if meta.contains(&needle) {
out.push((kind, meta));
}
})
.expect("scan the log");
out
}
}
// ── f: a repository moves out of the clan ───────────────────────────────────
/// ★ **The clan's admin offers; only the offered account's admin takes it,
/// and then it MOVES** — every object, reference, HEAD and LFS object, as
/// frames the log already carries — the clan's room is freed, the offer is
/// gone, and the clan, empty, dissolves.
#[tokio::test]
async fn a_repository_offered_out_of_the_clan_moves_when_the_accounts_admin_takes_it() {
let rig = Rig::new();
rig.fellows(0, 64 * MIB).await;
rig.make_repo("bob", "app").await;
let (head, lfs) = rig.fill(CLAN, "app", b"a clan's repository\n", 4096);
let lfs = lfs.expect("an LFS object");
assert!(
rig.usage_of(CLAN).pack_bytes > 0 && rig.usage_of(CLAN).lfs_bytes >= 4096,
"the fixture is on disk"
);
// Only the clan's admin offers; a member and a server admin do not.
assert_eq!(
rig.offer("bob", "app", "dave")
.await
.expect_err("a member")
.code(),
Code::PermissionDenied
);
assert_eq!(
rig.offer("root", "app", "dave")
.await
.expect_err("a server admin")
.code(),
Code::NotFound
);
assert_eq!(
rig.offer("alice", "nope", "dave")
.await
.expect_err("no such repo")
.code(),
Code::NotFound
);
assert_eq!(
rig.offer("alice", "app", CLAN)
.await
.expect_err("into a clan")
.code(),
Code::InvalidArgument
);
let clan = rig
.offer("alice", "app", "dave")
.await
.expect("alice offers app to dave");
assert_eq!(clan.offers.len(), 1, "the admin sees the offer");
assert_eq!(clan.offers[0].to_account, "dave");
assert!(
rig.get("bob").await.unwrap().offers.is_empty(),
"a member is not shown the offers"
);
// dave is told of it; carol is not, and may not answer it.
let listed = rig
.clans
.list(with(&person("dave"), ListClansRequest {}))
.await
.unwrap()
.into_inner();
assert_eq!(
listed.offers.len(),
1,
"dave's own list carries the offer: {listed:?}"
);
assert!(
listed.offers[0].stored_bytes.is_some_and(|b| b > 0),
"with what the repository stores"
);
assert!(listed.clans.is_empty(), "the offer shows dave no clan");
let carol = rig
.clans
.list(with(&person("carol"), ListClansRequest {}))
.await
.unwrap()
.into_inner();
assert!(carol.offers.is_empty());
assert_eq!(
rig.answer("carol", "app", true)
.await
.expect_err("not carol's")
.code(),
Code::NotFound
);
// The clan's admin may not take it for dave.
assert_eq!(
rig.answer("alice", "app", true)
.await
.expect_err("not alice's account")
.code(),
Code::PermissionDenied
);
// dave takes it: it moves.
let answered = rig
.answer("dave", "app", true)
.await
.expect("dave takes app");
let moved = answered.repo.expect("the moved repository");
assert_eq!(moved.path, Some(RepoPath::new("dave", "app")));
assert_eq!(
moved.visibility,
Visibility::Private as i32,
"it moves as it was"
);
assert!(
rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_err(),
"the clan still holds it"
);
let to = rig
.stores
.catalog()
.get(&RepoRef::new("dave", "app").unwrap())
.unwrap()
.expect("dave/app");
let store = rig.stores.repo_store(&to).expect("open dave/app");
let refs = store.serve().refs().expect("refs");
let main = refs
.iter()
.find(|r| r.name == "refs/heads/main")
.expect("main moved");
assert_eq!(
main.oid.as_deref().map(hex::encode),
Some(head.clone()),
"main points where it pointed"
);
let head_oid = gix_hash::ObjectId::from_hex(head.as_bytes()).expect("hex");
assert!(
store
.serve()
.get(head_oid.as_slice())
.expect("read")
.is_some(),
"the commit is readable at dave/app"
);
assert_eq!(
store
.serve()
.head()
.unwrap()
.and_then(|h| h.symref_target)
.as_deref(),
Some("refs/heads/main")
);
let moved_lfs = gunnar_lfs::FsLfsStore::open_or_init(store.git_dir()).expect("lfs");
assert_eq!(
moved_lfs.size_of(&lfs).expect("size"),
Some(4096),
"the LFS object moved"
);
drop(store);
// The clan's room is freed, the offer is closed, and the move is in the
// log as frames a twin already applies.
let usage = rig.usage_of(CLAN);
assert_eq!(
(usage.pack_bytes, usage.lfs_bytes, usage.repos),
(0, 0, 0),
"the clan still stores something"
);
assert!(
rig.get("alice").await.unwrap().offers.is_empty(),
"the offer is still open"
);
let frames = rig.frames_of("dave/app");
for (kind, what) in [(1u16, "the pack"), (2, "the refs"), (6, "the LFS object")] {
assert!(
frames.iter().any(|(k, _)| *k == kind),
"no {what} frame for dave/app: {frames:?}"
);
}
assert!(
rig.frames_of(&format!("{CLAN}/app"))
.iter()
.any(|(k, m)| *k == 8 && m.contains("purged")),
"the clan's copy was not purged in the log"
);
// Empty now: the clan dissolves.
rig.clans
.dissolve(with(
&person("alice"),
DissolveClanRequest { clan: CLAN.into() },
))
.await
.expect("an empty clan dissolves");
}
/// ★ **Declined, withdrawn, or not fitting the account's room: nothing
/// moves.** One open offer per repository; a repository already at the
/// target's name is refused.
#[tokio::test]
async fn a_declined_or_withdrawn_offer_moves_nothing() {
let rig = Rig::new();
rig.fellows(0, 64 * MIB).await;
rig.make_repo("bob", "app").await;
rig.fill(CLAN, "app", b"stays\n", 0);
rig.offer("alice", "app", "dave").await.expect("offered");
let err = rig
.offer("alice", "app", "carol")
.await
.expect_err("one open offer per repository");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
let declined = rig
.answer("dave", "app", false)
.await
.expect("dave declines");
assert!(declined.repo.is_none());
assert!(
rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_ok(),
"a declined offer moved it"
);
assert!(rig.plane.get_repo(&RepoPath::new("dave", "app")).is_err());
rig.offer("alice", "app", "carol")
.await
.expect("offered again");
let withdrawn = rig
.answer("alice", "app", false)
.await
.expect("the clan's admin withdraws");
assert!(withdrawn.repo.is_none());
assert!(rig.get("alice").await.unwrap().offers.is_empty());
assert_eq!(
rig.answer("carol", "app", true)
.await
.expect_err("withdrawn")
.code(),
Code::NotFound
);
// carol already has an `app`: taking it is refused and nothing moves.
rig.plane
.create_repo(gunnar_grpc::NewRepo {
path: RepoPath::new("carol", "app"),
hash_kind: HashKind::Sha256,
default_branch: String::new(),
visibility: Visibility::Private,
description: String::new(),
adopt: false,
})
.expect("carol/app");
rig.offer("alice", "app", "carol")
.await
.expect("offered to carol");
let err = rig
.answer("carol", "app", true)
.await
.expect_err("the name is taken in carol's account");
assert_eq!(err.code(), Code::AlreadyExists, "{err:?}");
assert!(
rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_ok(),
"a refused take moved it"
);
// The clan, still holding it, does not dissolve.
let err = rig
.clans
.dissolve(with(
&person("alice"),
DissolveClanRequest { clan: CLAN.into() },
))
.await
.expect_err("not empty");
assert_eq!(err.code(), Code::FailedPrecondition);
}
// ── metered: d (a lapsed member's share) and h (LFS in the clan's room) ────
#[cfg(feature = "metered")]
mod metered {
use super::*;
use ed25519_dalek::{Signer as _, SigningKey};
use gunnar_common::monetize_key::MonetizePubkey;
use gunnar_proto::usage_server::Usage as _;
use gunnar_server::entitlement::{EntitlementSlot, MeteredConfig};
use gunnar_ssh::command::GitService;
use gunnar_ssh::server::{Dispatch, DispatchError};
use gunnar_ssh::PlanGate as _;
use monetize_embed::{State, TenantId};
const DAY: u64 = 86_400_000;
struct Metered {
rig: Rig,
slot: Arc<EntitlementSlot>,
key: SigningKey,
minted: std::sync::atomic::AtomicU64,
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
impl Metered {
fn new() -> Metered {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let key = SigningKey::from_bytes(&[7u8; 32]);
let pubkey = dir.path().join("monetize.pub");
std::fs::write(&pubkey, key.verifying_key().to_bytes()).expect("write key");
let config = MeteredConfig::load(
&MonetizePubkey::Path(pubkey),
Some("https://gunnar.rs/billing/{tenant}"),
)
.expect("config");
let slot = EntitlementSlot::open(Arc::clone(&stores), &config).expect("slot");
let plane = Arc::new(
CoreControlPlane::new(Arc::clone(&stores)).with_entitlement(Arc::clone(&slot)),
);
Metered {
rig: Rig::with(dir, stores, plane),
slot,
key,
minted: std::sync::atomic::AtomicU64::new(0),
}
}
/// `ns` holds `cap` bytes, in `state`, paid until `paid_until`.
fn fact(&self, ns: &str, cap: u64, state: State, paid_until: u64) {
let issued = 1_789_000_000_000
+ self
.minted
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut fact = monetize_embed::EntitlementFact {
tenant: TenantId(ns.to_owned()),
plan: "room".into(),
state,
paid_until_unix_ms: Some(paid_until),
caps: [("pack_bytes".to_owned(), cap)].into_iter().collect(),
source: "payment:test".into(),
signature: vec![],
issued_unix_ms: Some(issued),
issued_signature: vec![],
};
fact.signature = self
.key
.sign(&monetize_embed::signing::fact_message(&fact))
.to_bytes()
.to_vec();
fact.issued_signature = self
.key
.sign(&monetize_embed::signing::fact_message_issued(&fact))
.to_bytes()
.to_vec();
self.rig
.plane
.entitlement_set(EntitlementFact {
namespace: ns.to_owned(),
plan: fact.plan,
state: match state {
State::Free => EntitlementState::Free,
State::Paid => EntitlementState::Paid,
State::Grace => EntitlementState::Grace,
State::Suspended => EntitlementState::Suspended,
State::Retention => EntitlementState::Retention,
} as i32,
paid_until_unix_ms: fact.paid_until_unix_ms,
caps: fact.caps,
source: fact.source,
signature: fact.signature,
issued_unix_ms: fact.issued_unix_ms,
issued_signature: fact.issued_signature,
})
.expect("the fact is accepted");
}
fn dispatch(&self) -> Dispatch {
Dispatch::new(
self.rig
.stores
.ssh_control()
.with_plan_gate(Arc::clone(&self.slot) as Arc<dyn gunnar_ssh::PlanGate>),
)
}
fn principal(&self, id: &str) -> gunnar_core::Principal {
self.rig
.stores
.auth()
.principal(id)
.expect("read")
.expect("the account")
}
async fn allowance(&self, who: &str, ns: &str) -> Allowance {
self.rig
.usage
.read(with(
&person(who),
ReadUsageRequest {
namespace: ns.into(),
},
))
.await
.expect("Usage.Read")
.into_inner()
.allowance
.expect("an allowance")
}
}
/// ★ **d — a member stops paying**: its share stays while its paid period
/// runs; once the period has ended the share drops — the clan's room in
/// force is the others', the member is drawn lapsed, the door holds the
/// clan to what is left, and the floor of the others counts only shares
/// in force.
#[tokio::test]
async fn a_members_share_stays_until_its_paid_period_ends_then_the_door_drops_it() {
let m = Metered::new();
m.fact("alice", 10 * GIB, State::Paid, now() + 30 * DAY);
m.fact("bob", 10 * GIB, State::Paid, now() + 30 * DAY);
m.rig.fellows(GIB, 2 * GIB).await;
m.rig.make_repo("bob", "app").await;
m.rig.fill(CLAN, "app", b"in the clan\n", 0);
let dispatch = m.dispatch();
let app = RepoRef::new(CLAN, "app").unwrap();
// bob stopped paying, but his period runs: his share stays.
m.fact("bob", 10 * GIB, State::Paid, now() + DAY);
let clan = m.rig.get("alice").await.unwrap();
assert_eq!(
clan.room_in_force_bytes,
3 * GIB,
"bob's share is in force while his period runs"
);
assert!(clan.members.iter().all(|mm| !mm.lapsed));
assert_eq!(
m.allowance("alice", CLAN)
.await
.pack_bytes
.map(|b| b + m.rig.usage_of(CLAN).lfs_bytes),
Some(3 * GIB)
);
// His period has ended (grace is his account's, not the clan's): the share drops.
m.fact("bob", 10 * GIB, State::Grace, now() - DAY);
let clan = m.rig.get("alice").await.unwrap();
assert_eq!(clan.room_bytes, 3 * GIB, "the row keeps bob's share");
assert_eq!(
clan.room_in_force_bytes, GIB,
"the door counts alice's alone"
);
assert!(
clan.members
.iter()
.any(|mm| mm.principal_id == "bob" && mm.lapsed),
"bob is drawn lapsed"
);
let ceiling = m
.slot
.pack_ceiling(CLAN)
.expect("a clan's push carries a ceiling");
let held = m.rig.usage_of(CLAN).pack_bytes;
assert!(
ceiling.bytes < 2 * GIB,
"the door still holds the clan to bob's dropped share: {}",
ceiling.bytes
);
assert_eq!(
m.allowance("alice", CLAN).await.lfs_bytes,
Some(GIB - held),
"Usage.Read says the room in force"
);
// alice's floor counts only shares in force: with bob's dropped, she
// may not go below what the clan stores.
let used = held + m.rig.usage_of(CLAN).lfs_bytes;
let err = m
.rig
.give("alice", 0)
.await
.expect_err("bob's lapsed share holds nothing up");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert_eq!(
m.rig.get("alice").await.unwrap().your_floor_bytes,
Some(used)
);
// bob, lapsed, may leave: his share is already out of force.
m.rig
.clans
.leave(with(&person("bob"), LeaveClanRequest { clan: CLAN.into() }))
.await
.expect("a lapsed member leaves");
// Over its room in force, the clan is held at the door like an account
// over its cap: alice gives the clan nothing, bob is gone.
m.fact("alice", 10 * GIB, State::Suspended, now() - DAY);
let err = dispatch
.authorise_service(&m.principal("alice"), None, GitService::ReceivePack, app)
.expect_err("a clan whose shares all lapsed takes no push");
let DispatchError::Plan { line, .. } = &err else {
panic!("{err:?}")
};
assert!(line.contains("clan fellows"), "{line}");
}
/// ★ **h — LFS counts in the clan's room**: packs and LFS objects are
/// held against ONE room, at the push door, at the LFS door and in
/// Usage.Read.
#[tokio::test]
async fn lfs_objects_count_in_the_clans_room() {
let m = Metered::new();
m.fact("alice", 10 * GIB, State::Paid, now() + 30 * DAY);
m.rig.fellows(0, 0).await;
m.rig.make_repo("bob", "app").await;
m.rig.fill(CLAN, "app", b"small\n", 8 << 20);
let usage = m.rig.usage_of(CLAN);
assert!(
usage.lfs_bytes >= 8 << 20 && usage.pack_bytes < usage.lfs_bytes,
"the fixture: {usage:?}"
);
// Room for the packs but not for packs AND the LFS object.
m.rig
.give("alice", usage.pack_bytes + 1)
.await
.expect("alice gives a little");
let dispatch = m.dispatch();
let err = dispatch
.authorise_service(
&m.principal("bob"),
None,
GitService::ReceivePack,
RepoRef::new(CLAN, "app").unwrap(),
)
.expect_err("the LFS object fills the clan's room");
let DispatchError::Plan { line, .. } = &err else {
panic!("{err:?}")
};
assert!(line.contains("pack quota"), "{line}");
let lfs = m
.slot
.lfs_allowance(CLAN)
.expect("a clan's LFS upload is held to its room");
assert_eq!(
lfs.bytes, 0,
"nothing left for LFS: packs and LFS share the room"
);
// Given room for both, the door opens and the LFS allowance is what is left.
let room = usage.pack_bytes + usage.lfs_bytes + MIB;
m.rig
.give("alice", room)
.await
.expect("alice gives enough for both");
dispatch
.authorise_service(
&m.principal("bob"),
None,
GitService::ReceivePack,
RepoRef::new(CLAN, "app").unwrap(),
)
.expect("room for packs and LFS");
assert_eq!(m.slot.lfs_allowance(CLAN).expect("held").bytes, MIB);
let allowed = m.allowance("bob", CLAN).await;
assert_eq!(
allowed.pack_bytes,
Some(room - usage.lfs_bytes),
"packs may hold the room less the LFS objects"
);
assert_eq!(
allowed.lfs_bytes,
Some(room - usage.pack_bytes),
"LFS may hold the room less the packs"
);
}
/// ★ **f — the account's own room must hold what it takes.**
#[tokio::test]
async fn an_account_takes_a_repository_only_if_its_own_room_holds_it() {
let m = Metered::new();
m.fact("alice", 10 * GIB, State::Paid, now() + 30 * DAY);
m.rig.fellows(GIB, 0).await;
m.rig.make_repo("bob", "app").await;
m.rig.fill(CLAN, "app", b"needs room\n", 200_000);
// dave's room is 100 KiB: too small.
m.fact("dave", 100 * 1024, State::Paid, now() + 30 * DAY);
m.rig.offer("alice", "app", "dave").await.expect("offered");
let err = m
.rig
.answer("dave", "app", true)
.await
.expect_err("dave's room does not hold it");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert!(err.message().contains("short"), "{}", err.message());
assert!(
m.rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_ok(),
"a refused take moved it"
);
// With room, it moves.
m.fact("dave", 10 * GIB, State::Paid, now() + 30 * DAY);
m.rig
.answer("dave", "app", true)
.await
.expect("now it fits");
assert!(m.rig.plane.get_repo(&RepoPath::new("dave", "app")).is_ok());
let _ = TenantId("unused".into());
}
}
/// **An encrypted repository moved out of the clan stays encrypted** (wiring
/// tracer B 2026-10-08). The move made the new repository from the old one's
/// hash, branch, visibility and description, and dropped the flag: the moved
/// repository then took plaintext pushes, and the members' promise was gone
/// without anyone turning it off.
#[tokio::test]
async fn an_encrypted_repository_moved_out_of_the_clan_stays_encrypted() {
let rig = Rig::new();
rig.fellows(0, 64 * MIB).await;
rig.make_repo("bob", "app").await;
let _ = rig.fill(CLAN, "app", b"sealed by the members\n", 0);
rig.plane
.set_encrypted(&RepoPath::new(CLAN, "app"), true)
.expect("the clan's admin turned encryption on");
rig.offer("alice", "app", "dave")
.await
.expect("alice offers app to dave");
let moved = rig
.answer("dave", "app", true)
.await
.expect("dave takes app")
.repo
.expect("the moved repository");
assert!(
moved.encrypted,
"the move dropped the encrypted flag: {moved:?}"
);
assert!(
rig.plane
.get_repo(&RepoPath::new("dave", "app"))
.expect("dave/app")
.encrypted,
"dave/app as the catalog holds it is not encrypted"
);
}