crates/gunnar-server/tests/members_never_buy.rs
//! **Members never buy** (owner, 2026-10-07: a customer is an ACCOUNT that
//! pays; MEMBERS — the users an account's admin enrols — use the account's
//! space and never reach the shop), over the REAL plane and stores.
//!
//! The members walkthrough of 2026-10-07 measured the hole this closes: `ben`,
//! an employee `acme` enrolled, pressed "Grow your account" → Upgrade, this
//! appliance vouched for an order for tenant `ben`
//! (`owns_account("ben", "ben")` is a string equality, and no namespace `ben`
//! exists), the fake Stripe took his money, and the page said "your room is
//! ready" for a namespace nobody holds. `Billing.MintActorTicket` is the one
//! door to the shop; it now refuses a member, and any name with no namespace,
//! by name and before anything is signed.
use std::sync::Arc;
use gunnar_grpc::{
AccountsService, BillingService, Caller, ControlPlane, EventBus, ServerConfig, ServiceContext,
};
use gunnar_proto::accounts_server::Accounts as _;
use gunnar_proto::billing_server::Billing as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use tonic::{Code, Request};
fn person(id: &str) -> Caller {
Caller {
principal_id: id.to_owned(),
fingerprint: format!("SHA256:{id}"),
machine: false,
authenticated: true,
}
}
fn authed<T>(caller: &Caller, body: T) -> Request<T> {
let mut r = Request::new(body);
r.extensions_mut().insert(caller.clone());
r
}
fn order(namespace: &str) -> MintActorTicketRequest {
MintActorTicketRequest {
namespace: namespace.to_owned(),
purpose: "order".to_owned(),
caps: [("pack_bytes".to_owned(), 10u64 << 30)]
.into_iter()
.collect(),
}
}
fn renew(namespace: &str) -> MintActorTicketRequest {
MintActorTicketRequest {
namespace: namespace.to_owned(),
purpose: "renew".to_owned(),
caps: Default::default(),
}
}
/// `acme`, a customer account (its principal and namespace, made together),
/// with `ben` enrolled by its admin; `kim`, an individual; `op`, the box's
/// operator (server Admin).
async fn world() -> (tempfile::TempDir, ServiceContext) {
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)));
let control: Arc<dyn ControlPlane> = plane.clone();
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(64)),
ServerConfig::default(),
);
plane
.add_principal("op", "op", PrincipalKind::User)
.expect("the operator");
plane
.grant("op", "", "", GrantLevel::Admin)
.expect("the operator is a server admin");
plane
.add_principal("acme", "acme", PrincipalKind::User)
.expect("a customer");
plane
.add_principal("kim", "kim", PrincipalKind::User)
.expect("an individual");
AccountsService::new(ctx.clone())
.enroll_employee(authed(
&person("acme"),
EnrollEmployeeRequest {
account: "acme".into(),
id: "ben".into(),
name: "Ben".into(),
..Default::default()
},
))
.await
.expect("acme enrols ben");
(dir, ctx)
}
/// ★ **ben, acme's member, is refused a ticket — for his own name, for the
/// account's, for any purpose — in words that name the account and whom to
/// ask.** acme and kim still buy.
#[tokio::test(flavor = "multi_thread")]
async fn a_member_is_refused_a_ticket_and_told_whom_to_ask() {
let (_dir, ctx) = world().await;
let billing = BillingService::new(ctx.clone());
let bare = |namespace: &str, purpose: &str| MintActorTicketRequest {
namespace: namespace.to_owned(),
purpose: purpose.to_owned(),
caps: Default::default(),
};
for (what, asked) in [
("ben's own name", order("ben")),
("a renewal of ben", renew("ben")),
("the account", order("acme")),
// Every purpose, not only the two that buy (wiring tracer B 2026-10-08): an estate at
// its seller, a cancellation, a read of what was sold — for his name and the account's.
("ben's estate", bare("ben", "estate")),
("the account's estate", bare("acme", "estate")),
("a cancel for ben", bare("ben", "cancel")),
("the account's cancel", bare("acme", "cancel")),
("a read of ben", bare("ben", "read")),
("the account's read", bare("acme", "read")),
] {
let refused = billing
.mint_actor_ticket(authed(&person("ben"), asked))
.await
.expect_err("a member buys nothing");
assert_eq!(
refused.code(),
Code::PermissionDenied,
"{what}: {refused:?}"
);
assert!(
refused.message().contains("ben is a member of acme")
&& refused.message().contains("Ask the admin of acme"),
"{what}: {}",
refused.message()
);
}
for who in ["acme", "kim"] {
let minted = billing
.mint_actor_ticket(authed(&person(who), order(who)))
.await
.unwrap_or_else(|e| panic!("{who} buys room for its own account: {e:?}"))
.into_inner();
assert!(!minted.ticket.is_empty(), "{who}");
}
}
/// ★ **No ticket for a name that has no namespace** — not for the server's
/// operator either. Asked after the grant check, so a stranger learns nothing.
#[tokio::test(flavor = "multi_thread")]
async fn a_name_with_no_namespace_is_refused_a_ticket() {
let (_dir, ctx) = world().await;
let billing = BillingService::new(ctx.clone());
let refused = billing
.mint_actor_ticket(authed(&person("op"), order("nobody")))
.await
.expect_err("room is bought for a namespace that exists");
assert_eq!(refused.code(), Code::NotFound, "{refused:?}");
assert!(
refused.message().contains("there is no namespace nobody"),
"{}",
refused.message()
);
let refused = billing
.mint_actor_ticket(authed(&person("kim"), order("nobody")))
.await
.expect_err("not kim's to ask");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
}
/// ★ **An appointed account admin decides for the account and never buys**
/// (owner 2026-10-08): acme gives ben Admin over its namespace. He may publish
/// and encrypt the account's repositories and read its audit log; WhoAmI says
/// `admin-appointed`; every ticket purpose is still refused, in the member's
/// words.
#[tokio::test(flavor = "multi_thread")]
async fn an_appointed_account_admin_decides_but_never_buys() {
use gunnar_proto::access_server::Access as _;
use gunnar_proto::repos_server::Repos as _;
let (_dir, ctx) = world().await;
ctx.control
.grant("ben", "acme", "", GrantLevel::Admin)
.expect("acme appoints ben");
ctx.control
.create_repo(gunnar_grpc::NewRepo {
path: RepoPath::new("acme", "site"),
hash_kind: HashKind::Sha256,
default_branch: String::new(),
visibility: Visibility::Private,
description: String::new(),
adopt: false,
})
.expect("acme's repository");
let me = gunnar_grpc::AccessService::new(ctx.clone())
.who_am_i(authed(&person("ben"), WhoAmIRequest {}))
.await
.expect("who")
.into_inner();
assert_eq!(
(
me.account.as_str(),
me.account_role.as_str(),
me.account_admin.as_str()
),
("acme", "admin-appointed", "acme"),
"{me:?}"
);
let repos = gunnar_grpc::ReposService::new(ctx.clone());
repos
.set_visibility(authed(
&person("ben"),
SetVisibilityRequest {
path: Some(RepoPath::new("acme", "site")),
visibility: Visibility::Public as i32,
},
))
.await
.expect("he publishes for the account");
repos
.set_encrypted(authed(
&person("ben"),
SetEncryptedRequest {
path: Some(RepoPath::new("acme", "site")),
encrypted: true,
},
))
.await
.expect("he encrypts for the account");
let billing = BillingService::new(ctx.clone());
for purpose in ["order", "renew", "estate", "cancel", "read"] {
let caps: std::collections::BTreeMap<String, u64> = if purpose == "order" {
[("pack_bytes".to_owned(), 1u64 << 30)]
.into_iter()
.collect()
} else {
Default::default()
};
for ns in ["ben", "acme"] {
let refused = billing
.mint_actor_ticket(authed(
&person("ben"),
MintActorTicketRequest {
namespace: ns.into(),
purpose: purpose.into(),
caps: caps.clone(),
},
))
.await
.expect_err("an appointed admin still never buys");
assert_eq!(
refused.code(),
Code::PermissionDenied,
"{purpose} for {ns}: {refused:?}"
);
assert!(
refused.message().contains("ben is a member of acme"),
"{purpose} for {ns}: {}",
refused.message()
);
}
}
}