use std::sync::Arc;
use gunnar_core::RepoRef;
use gunnar_grpc::{
Access, AccountsService, Caller, ClansService, ControlPlane, EventBus, Identity as _,
ReposService, ServerConfig, ServiceContext, SsoService,
};
use gunnar_proto::accounts_server::Accounts as _;
use gunnar_proto::clans_server::Clans as _;
use gunnar_proto::repos_server::Repos as _;
use gunnar_proto::sso_server::Sso as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use tonic::{Code, Request};
const CLAN: &str = "fellows";
const MIB: u64 = 1 << 20;
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
.extensions_mut()
.insert(gunnar_grpc::auth::PersonSession {
stepped_up: true,
stepped_up_at_unix_ms: None,
});
request
}
struct Rig {
_dir: tempfile::TempDir,
stores: Arc<ServerStores>,
plane: Arc<CoreControlPlane>,
clans: ClansService,
repos: ReposService,
accounts: AccountsService,
#[cfg(feature = "metered")]
usage: gunnar_grpc::UsageService,
sso: SsoService,
}
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)
}
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", "acme", "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()),
sso: SsoService::new(ctx.clone()),
accounts: AccountsService::new(ctx),
plane,
}
}
async fn enroll_eve(&self) {
self.accounts
.enroll_employee(with(
&person("acme"),
EnrollEmployeeRequest {
account: "acme".into(),
id: "eve".into(),
name: "eve".into(),
..Default::default()
},
))
.await
.expect("acme enrolls eve: an enterprise account");
}
async fn create(&self, who: &str, name: &str, give: u64) -> Result<Clan, tonic::Status> {
self.clans
.create(with(
&person(who),
CreateClanRequest {
name: name.into(),
contribution_bytes: give,
},
))
.await
.map(|r| r.into_inner().clan.expect("a clan comes back"))
}
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 comes back"))
}
async fn invite(&self, who: &str, whom: &str) -> Result<Clan, tonic::Status> {
self.clans
.invite(with(
&person(who),
InviteToClanRequest {
clan: CLAN.into(),
principal_id: whom.into(),
},
))
.await
.map(|r| r.into_inner().clan.expect("a clan comes back"))
}
async fn accept(&self, who: &str, give: u64) -> Result<Clan, tonic::Status> {
self.clans
.accept(with(
&person(who),
AcceptClanInviteRequest {
clan: CLAN.into(),
contribution_bytes: give,
},
))
.await
.map(|r| r.into_inner().clan.expect("a clan comes back"))
}
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 comes back"))
}
async fn leave(&self, who: &str) -> Result<(), tonic::Status> {
self.clans
.leave(with(&person(who), LeaveClanRequest { clan: CLAN.into() }))
.await
.map(|_| ())
}
async fn fellows(&self, alice: u64, bob: u64) {
self.create("alice", CLAN, alice)
.await
.expect("alice makes the clan");
self.invite("alice", "bob")
.await
.expect("alice invites bob");
self.accept("bob", bob).await.expect("bob joins");
}
async fn make_repo(
&self,
who: &str,
name: &str,
visibility: Visibility,
) -> Result<Repo, tonic::Status> {
self.repos
.create(with(
&person(who),
CreateRepoRequest {
path: Some(RepoPath::new(CLAN, name)),
hash_kind: HashKind::Sha256 as i32,
visibility: visibility as i32,
..Default::default()
},
))
.await
.map(|r| r.into_inner().repo.expect("a repo comes back"))
}
async fn set_visibility(
&self,
who: &str,
name: &str,
to: Visibility,
) -> Result<Repo, tonic::Status> {
self.repos
.set_visibility(with(
&person(who),
SetVisibilityRequest {
path: Some(RepoPath::new(CLAN, name)),
visibility: to as i32,
},
))
.await
.map(|r| r.into_inner().repo.expect("a repo comes back"))
}
fn visibility(&self, name: &str) -> i32 {
self.plane
.get_repo(&RepoPath::new(CLAN, name))
.expect("the repo")
.visibility
}
fn seed(&self, name: &str, bytes: usize) -> u64 {
let path = RepoRef::new(CLAN, name).expect("legal");
let repo = self
.stores
.catalog()
.get(&path)
.expect("read")
.expect("the repo exists");
let handle = self.stores.repo_store(&repo).expect("open");
let mut pb = PackBuilder::new(gix_hash::Kind::Sha256);
pb.object(gix_object::Kind::Blob, vec![b'q'; bytes]);
handle
.serve()
.put_pack(&pb.build().expect("pack").bytes)
.expect("put_pack");
drop(handle);
self.stores.close_repo(&path);
self.used()
}
fn used(&self) -> u64 {
self.settled()
}
fn settled(&self) -> u64 {
let read = || self.plane.usage(Some(CLAN)).expect("usage")[0].pack_bytes;
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 now == last { same + 1 } else { 0 };
last = now;
if same >= 3 {
break;
}
}
last
}
}
fn member<'a>(clan: &'a Clan, who: &str) -> Option<&'a ClanMember> {
clan.members.iter().find(|m| m.principal_id == who)
}
#[tokio::test]
async fn a_member_makes_private_repositories_and_only_the_clan_admin_publishes_or_deletes_them() {
let rig = Rig::new();
rig.fellows(0, 0).await;
rig.make_repo("bob", "app", Visibility::Private)
.await
.expect("a member makes a private repository");
let err = rig
.make_repo("bob", "open", Visibility::Public)
.await
.expect_err("born public is a publish");
assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
assert!(
rig.plane.get_repo(&RepoPath::new(CLAN, "open")).is_err(),
"the refused repository was made anyway"
);
let app = Access::repo(&RepoPath::new(CLAN, "app"));
assert!(
rig.plane.may("bob", &app, GrantLevel::Write).unwrap(),
"a member writes the clan's repositories"
);
assert!(
!rig.plane.may("bob", &app, GrantLevel::Admin).unwrap(),
"a member does not administer them"
);
assert!(
rig.plane.may("alice", &app, GrantLevel::Admin).unwrap(),
"the clan admin administers what a member made"
);
assert!(
!rig.plane.may("carol", &app, GrantLevel::Read).unwrap(),
"an outsider reads nothing of the clan's"
);
let err = rig
.set_visibility("bob", "app", Visibility::Public)
.await
.expect_err("a member may not publish");
assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
assert_eq!(
rig.visibility("app"),
Visibility::Private as i32,
"a refused publish published"
);
rig.set_visibility("alice", "app", Visibility::Public)
.await
.expect("the clan admin publishes");
assert_eq!(rig.visibility("app"), Visibility::Public as i32);
assert!(
!rig.plane
.may("bob", &Access::namespace(CLAN), GrantLevel::Admin)
.unwrap(),
"a member administers the clan"
);
assert!(rig
.plane
.may("alice", &Access::namespace(CLAN), GrantLevel::Admin)
.unwrap());
let delete = |who: &str| {
rig.repos.delete(with(
&person(who),
DeleteRepoRequest {
path: Some(RepoPath::new(CLAN, "app")),
..Default::default()
},
))
};
let err = delete("bob").await.expect_err("a member may not delete");
assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
assert!(
rig.plane.get_repo(&RepoPath::new(CLAN, "app")).is_ok(),
"a refused delete deleted"
);
delete("alice").await.expect("the clan admin deletes");
let err = rig
.make_repo("carol", "mine", Visibility::Private)
.await
.expect_err("an outsider");
assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
}
#[tokio::test]
async fn a_clans_namespace_and_events_are_private_and_to_an_outsider_it_does_not_exist() {
let rig = Rig::new();
rig.fellows(0, 0).await;
let ns = rig
.plane
.namespace(CLAN)
.expect("ask")
.expect("the clan's namespace exists");
assert!(
!ns.public && !ns.events_public,
"not public by default: {ns:?}"
);
let listed = |who: &str| {
rig.repos
.list_namespaces(with(&person(who), ListNamespacesRequest::default()))
};
let carol: Vec<String> = listed("carol")
.await
.unwrap()
.into_inner()
.namespaces
.into_iter()
.map(|n| n.name)
.collect();
assert!(
!carol.iter().any(|n| n == CLAN),
"an outsider is shown the clan's namespace: {carol:?}"
);
let bob: Vec<String> = listed("bob")
.await
.unwrap()
.into_inner()
.namespaces
.into_iter()
.map(|n| n.name)
.collect();
assert!(
bob.iter().any(|n| n == CLAN),
"a member is not shown its clan: {bob:?}"
);
let outsider = rig.get("carol").await.expect_err("carol is not in it");
let nothing = rig
.clans
.get(with(
&person("carol"),
GetClanRequest {
clan: "nope".into(),
},
))
.await
.expect_err("there is no such clan");
assert_eq!(
(outsider.code(), nothing.code()),
(Code::NotFound, Code::NotFound)
);
let open = |who: &str| {
rig.repos.set_namespace_settings(with(
&person(who),
SetNamespaceSettingsRequest {
name: CLAN.into(),
public: Some(true),
events_public: None,
},
))
};
assert_eq!(
open("bob").await.expect_err("a member").code(),
Code::PermissionDenied
);
assert!(
open("alice")
.await
.expect("the admin")
.into_inner()
.namespace
.unwrap()
.public
);
}
#[tokio::test]
async fn the_clans_room_is_the_sum_of_its_members_contributions() {
let rig = Rig::new();
rig.fellows(3 * GIB, 2 * GIB).await;
let clan = rig.get("bob").await.expect("bob sees his clan");
assert_eq!(clan.room_bytes, 5 * GIB, "3 GiB + 2 GiB");
assert_eq!(member(&clan, "alice").unwrap().contribution_bytes, 3 * GIB);
assert_eq!(clan.your_contribution_bytes, 2 * GIB, "bob's own");
assert_eq!(clan.your_role, ClanRole::Member as i32);
let clan = rig.give("bob", 4 * GIB).await.expect("bob gives more");
assert_eq!(clan.room_bytes, 7 * GIB, "3 GiB + 4 GiB");
rig.invite("alice", "carol")
.await
.expect("alice invites carol");
assert!(
rig.get("bob").await.unwrap().invites.is_empty(),
"a member is shown the admin's invitations"
);
assert_eq!(rig.get("alice").await.unwrap().invites.len(), 1);
}
#[tokio::test]
async fn a_contribution_goes_down_only_to_what_the_clan_stores() {
let rig = Rig::new();
rig.fellows(0, 64 * MIB).await;
rig.make_repo("bob", "app", Visibility::Private)
.await
.expect("repo");
let used = rig.seed("app", 200_000);
assert!(used > 0, "the fixture is on disk");
let clan = rig.get("bob").await.unwrap();
assert_eq!(
clan.used_bytes,
Some(used),
"gunnar measured what the clan stores"
);
assert_eq!(
clan.your_floor_bytes,
Some(used),
"alice gives nothing: bob's floor is all of it"
);
let err = rig
.give("bob", used - 1)
.await
.expect_err("below what the clan stores");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert!(
err.message().contains("may go down to"),
"{}",
err.message()
);
assert_eq!(
rig.get("bob").await.unwrap().your_contribution_bytes,
64 * MIB,
"a refused lowering lowered"
);
rig.give("bob", used)
.await
.expect("down to what the clan stores");
rig.give("alice", used).await.expect("alice gives");
let clan = rig.give("bob", 0).await.expect("the others cover it");
assert_eq!(clan.room_bytes, used);
}
#[tokio::test]
async fn leaving_gives_the_contribution_back_and_is_refused_while_it_is_in_use() {
let rig = Rig::new();
rig.fellows(0, 64 * MIB).await;
rig.make_repo("bob", "app", Visibility::Private)
.await
.expect("repo");
let used = rig.seed("app", 200_000);
let err = rig.leave("bob").await.expect_err("bob's room is in use");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert!(
member(&rig.get("alice").await.unwrap(), "bob").is_some(),
"a refused leave left"
);
rig.give("alice", used)
.await
.expect("alice covers what the clan stores");
rig.leave("bob").await.expect("now bob may leave");
let clan = rig.get("alice").await.unwrap();
assert!(member(&clan, "bob").is_none());
assert_eq!(clan.room_bytes, used, "bob's contribution went with him");
assert_eq!(
rig.get("bob").await.expect_err("bob is out").code(),
Code::NotFound
);
assert!(
!rig.plane
.may(
"bob",
&Access::repo(&RepoPath::new(CLAN, "app")),
GrantLevel::Read
)
.unwrap(),
"a member who left still reads the clan's repositories"
);
}
#[tokio::test]
async fn the_last_admin_can_neither_leave_nor_be_unmade() {
let rig = Rig::new();
rig.fellows(0, 0).await;
let err = rig.leave("alice").await.expect_err("the last admin");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert!(err.message().contains("last admin"), "{}", err.message());
let unmake = |who: &str, whom: &str, admin: bool| {
rig.clans.set_admin(with(
&person(who),
SetClanAdminRequest {
clan: CLAN.into(),
principal_id: whom.into(),
admin,
},
))
};
assert_eq!(
unmake("alice", "alice", false)
.await
.expect_err("last admin")
.code(),
Code::FailedPrecondition
);
let err = rig
.clans
.remove_member(with(
&person("alice"),
RemoveClanMemberRequest {
clan: CLAN.into(),
principal_id: "alice".into(),
},
))
.await
.expect_err("the last admin removes itself");
assert!(
err.message().contains("cannot remove itself"),
"{}",
err.message()
);
assert_eq!(
member(&rig.get("alice").await.unwrap(), "alice").map(|m| m.admin),
Some(true),
"still its admin"
);
unmake("alice", "bob", true)
.await
.expect("alice makes bob admin");
rig.leave("alice").await.expect("now alice may leave");
let clan = rig.get("bob").await.unwrap();
assert_eq!(clan.your_role, ClanRole::Admin as i32);
assert_eq!(clan.members.len(), 1);
}
#[tokio::test]
async fn an_enterprise_employee_joins_no_clan_and_makes_none() {
let rig = Rig::new();
rig.enroll_eve().await;
rig.create("alice", CLAN, 0).await.expect("clan");
let err = rig
.invite("alice", "eve")
.await
.expect_err("eve is acme's employee");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert!(
err.message().contains("employee of acme"),
"{}",
err.message()
);
let err = rig
.create("eve", "evesclan", 0)
.await
.expect_err("an employee makes no clan");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert!(
rig.plane.namespace("evesclan").unwrap().is_none(),
"a refused clan left its namespace behind"
);
let eve = rig
.clans
.list(with(&person("eve"), ListClansRequest {}))
.await
.unwrap()
.into_inner();
assert!(eve.clans.is_empty());
rig.invite("alice", "acme")
.await
.expect("acme itself may be invited");
rig.clans
.accept(with(
&person("acme"),
AcceptClanInviteRequest {
clan: CLAN.into(),
contribution_bytes: 0,
},
))
.await
.expect("acme joins");
}
#[tokio::test]
async fn only_the_clan_admin_invites_removes_promotes_and_dissolves() {
let rig = Rig::new();
rig.fellows(0, 0).await;
let err = rig
.invite("bob", "carol")
.await
.expect_err("a member invites nobody");
assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
let err = rig
.invite("root", "carol")
.await
.expect_err("a server admin is not the clan's admin");
assert_eq!(
err.code(),
Code::NotFound,
"to an outsider the clan does not exist: {err:?}"
);
let remove = |who: &str, whom: &str| {
rig.clans.remove_member(with(
&person(who),
RemoveClanMemberRequest {
clan: CLAN.into(),
principal_id: whom.into(),
},
))
};
assert_eq!(
remove("bob", "alice")
.await
.expect_err("a member removes nobody")
.code(),
Code::PermissionDenied
);
let dissolve = |who: &str| {
rig.clans.dissolve(with(
&person(who),
DissolveClanRequest { clan: CLAN.into() },
))
};
assert_eq!(
dissolve("bob")
.await
.expect_err("a member dissolves nothing")
.code(),
Code::PermissionDenied
);
rig.invite("alice", "carol")
.await
.expect("the admin invites");
let err = rig
.accept("dave", 0)
.await
.expect_err("dave was not invited");
assert_eq!(err.code(), Code::NotFound, "{err:?}");
rig.clans
.decline(with(
&person("carol"),
DeclineClanInviteRequest {
clan: CLAN.into(),
principal_id: String::new(),
},
))
.await
.expect("carol declines");
assert!(rig.get("alice").await.unwrap().invites.is_empty());
remove("alice", "bob")
.await
.expect("the admin removes a member");
rig.make_repo("alice", "keep", Visibility::Private)
.await
.expect("repo");
let err = dissolve("alice").await.expect_err("it holds a repository");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
rig.repos
.delete(with(
&person("alice"),
DeleteRepoRequest {
path: Some(RepoPath::new(CLAN, "keep")),
purge: true,
..Default::default()
},
))
.await
.expect("purged");
dissolve("alice").await.expect("an empty clan is dissolved");
assert!(
rig.plane.namespace(CLAN).unwrap().is_none(),
"the namespace went with the clan"
);
assert_eq!(
rig.get("alice").await.expect_err("gone").code(),
Code::NotFound
);
}
#[tokio::test]
async fn a_clans_name_is_one_claim_with_the_namespaces_and_the_accounts() {
let rig = Rig::new();
let err = rig
.create("alice", "bob", 0)
.await
.expect_err("bob is an account");
assert_eq!(err.code(), Code::AlreadyExists, "{err:?}");
rig.create("alice", CLAN, 0).await.expect("clan");
let err = rig.create("carol", CLAN, 0).await.expect_err("taken");
assert_eq!(err.code(), Code::AlreadyExists, "{err:?}");
assert!(
rig.plane
.add_principal(CLAN, CLAN, PrincipalKind::User)
.is_err(),
"an account was made under a clan's name"
);
let err = rig
.accounts
.enroll_employee(with(
&person("alice"),
EnrollEmployeeRequest {
account: CLAN.into(),
id: "zed".into(),
name: "zed".into(),
..Default::default()
},
))
.await
.expect_err("a clan is no account: it enrolls no employees");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
let err = rig
.repos
.delete_namespace(with(
&person("alice"),
DeleteNamespaceRequest {
name: CLAN.into(),
delete_repos: true,
},
))
.await
.expect_err("the clan's namespace goes with the clan");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
}
#[tokio::test]
async fn a_clan_is_no_account_to_company_sso() {
let rig = Rig::new();
rig.create("alice", CLAN, 0).await.expect("clan");
let refused_as_clan = |err: tonic::Status, verb: &str| {
assert_eq!(err.code(), Code::FailedPrecondition, "{verb}: {err:?}");
assert!(
err.message().contains("is a clan, not an account"),
"{verb}: {}",
err.message()
);
};
let err = rig
.sso
.set_provider(with(
&person("alice"),
SetProviderRequest {
provider: Some(SsoProvider {
account: CLAN.into(),
protocol: SsoProtocol::Oidc as i32,
display_name: "fellows corp".into(),
domains: vec!["fellows.example".into()],
oidc: Some(OidcSettings {
issuer: "https://idp.fellows.example".into(),
client_id: "gunnar-fellows".into(),
..Default::default()
}),
console_origin: "https://gunnar.example".into(),
..Default::default()
}),
client_secret: "never-stored".into(),
clear_client_secret: false,
clear_idp_ca: false,
},
))
.await
.expect_err("a clan has no identity provider");
refused_as_clan(err, "Sso.SetProvider");
let err = rig
.sso
.create_scim_token(with(
&person("alice"),
CreateScimTokenRequest {
account: CLAN.into(),
label: "directory".into(),
..Default::default()
},
))
.await
.expect_err("a clan has no SCIM bearer");
refused_as_clan(err, "Sso.CreateScimToken");
let err = rig
.sso
.import_users(with(
&person("alice"),
ImportUsersRequest {
account: CLAN.into(),
csv: "user_name,display_name,email,active\nzed@fellows.example,Zed,,true\n".into(),
dry_run: false,
},
))
.await
.expect_err("a clan imports no employees");
refused_as_clan(err, "Sso.ImportUsers");
let err = rig
.sso
.list_directory(with(
&person("alice"),
ListDirectoryRequest {
account: CLAN.into(),
},
))
.await
.expect_err("a clan has no directory");
refused_as_clan(err, "Sso.ListDirectory");
let auth = rig.stores.auth();
assert!(
auth.sso_provider(CLAN).unwrap().is_none(),
"a provider was written for the clan"
);
assert!(
auth.scim_tokens_of(CLAN).unwrap().is_empty(),
"a SCIM bearer was written for the clan"
);
assert!(
auth.sso_users_of(CLAN).unwrap().is_empty(),
"a directory user was written for the clan"
);
rig.sso
.create_scim_token(with(
&person("alice"),
CreateScimTokenRequest {
account: "alice".into(),
label: "directory".into(),
..Default::default()
},
))
.await
.expect("alice's own account takes a SCIM bearer");
}
#[tokio::test]
async fn a_removed_account_leaves_its_clans_and_one_it_ran_goes_to_its_longest_member() {
let rig = Rig::new();
rig.fellows(0, 2 * GIB).await;
rig.invite("alice", "carol").await.unwrap();
rig.accept("carol", GIB).await.unwrap();
rig.plane
.remove_principal("alice")
.expect("alice's account is removed");
let clan = rig.get("bob").await.expect("bob is still in it");
assert!(member(&clan, "alice").is_none());
assert_eq!(
clan.your_role,
ClanRole::Admin as i32,
"bob joined first and runs it now"
);
assert_eq!(clan.room_bytes, 3 * GIB);
}
#[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, PACK_CEILING_SLACK_DIVISOR, PACK_CEILING_SLACK_FLOOR_BYTES,
};
use gunnar_ssh::command::GitService;
use gunnar_ssh::server::{Dispatch, DispatchError};
use gunnar_ssh::PlanGate as _;
use monetize_embed::{State, TenantId};
struct Metered {
rig: Rig,
slot: Arc<EntitlementSlot>,
key: SigningKey,
minted: std::sync::atomic::AtomicU64,
}
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),
}
}
fn room(&self, ns: &str, cap: 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: State::Paid,
paid_until_unix_ms: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
+ 30 * 86_400_000,
),
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: EntitlementState::Paid 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")
}
}
#[tokio::test]
async fn a_contribution_comes_out_of_the_members_own_room() {
let m = Metered::new();
m.room("alice", 10 * GIB);
let err = m
.rig
.create("alice", CLAN, 11 * GIB)
.await
.expect_err("more than alice has");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert!(err.message().contains("short"), "{}", err.message());
assert!(
m.rig.plane.namespace(CLAN).unwrap().is_none(),
"a refused clan left its namespace"
);
let clan = m
.rig
.create("alice", CLAN, 6 * GIB)
.await
.expect("6 of alice's 10 GiB");
assert_eq!(
clan.your_room_left_bytes,
Some(4 * GIB),
"what alice could still give"
);
assert_eq!(
m.rig
.give("alice", 11 * GIB)
.await
.expect_err("past her room")
.code(),
Code::FailedPrecondition
);
m.rig.give("alice", 10 * GIB).await.expect("all of it");
m.rig
.give("alice", 6 * GIB)
.await
.expect("back to 6 (the clan stores nothing)");
let err = m
.rig
.accounts
.enroll_employee(with(
&person("alice"),
EnrollEmployeeRequest {
account: "alice".into(),
id: "kim".into(),
name: "kim".into(),
room_bytes: 0,
..Default::default()
},
))
.await
.expect_err("6 GiB given + 10 GiB asked > 10 GiB");
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
}
#[tokio::test]
async fn the_door_holds_a_clan_to_its_members_contributions_and_a_member_to_what_it_kept() {
let m = Metered::new();
m.room("alice", 10 * GIB);
m.rig.fellows(0, 0).await;
m.rig
.make_repo("bob", "app", Visibility::Private)
.await
.expect("repo");
let dispatch = m.dispatch();
let bob = m.principal("bob");
let app = RepoRef::new(CLAN, "app").unwrap();
let err = dispatch
.authorise_service(&bob, None, GitService::ReceivePack, app.clone())
.expect_err("a clan with no room takes no push");
let DispatchError::Plan { line, .. } = &err else {
panic!("{err:?}")
};
assert!(
line.contains("pack quota") && line.contains("clan fellows"),
"{line}"
);
m.rig
.give("alice", 3 * GIB)
.await
.expect("alice gives 3 GiB");
dispatch
.authorise_service(&bob, None, GitService::ReceivePack, app.clone())
.expect("a member pushes into the clan's room");
let ceiling = m
.slot
.pack_ceiling(CLAN)
.expect("a clan's push carries a ceiling");
let slack = (3 * GIB / PACK_CEILING_SLACK_DIVISOR).max(PACK_CEILING_SLACK_FLOOR_BYTES);
assert_eq!(
ceiling.bytes,
3 * GIB - m.rig.used() + slack,
"the clan's room, less what it holds, plus the slack"
);
let own = m.slot.pack_ceiling("alice").expect("alice is capped");
let kept = 7 * GIB;
let slack = (kept / PACK_CEILING_SLACK_DIVISOR).max(PACK_CEILING_SLACK_FLOOR_BYTES);
assert_eq!(own.bytes, kept + slack, "alice's own room is what she kept");
let usage = &m.rig.usage;
let allowed = |who: &'static str, ns: &'static str| async move {
usage
.read(with(
&person(who),
ReadUsageRequest {
namespace: ns.into(),
},
))
.await
.expect("Usage.Read")
.into_inner()
.allowance
.expect("an allowance")
.pack_bytes
};
assert_eq!(
allowed("bob", CLAN).await,
Some(3 * GIB),
"a clan's allowance is its members' sum"
);
assert_eq!(
allowed("alice", "alice").await,
Some(kept),
"alice's own allowance is what she kept"
);
}
}