crates/gunnar-grpc/tests/browse.rs
//! **The browse replica's control plane refuses every mutator**, at the seam
//! every handler passes through — `ServiceContext::
//! require` / `require_repo_or_namespace` — against the in-memory control
//! plane, so the matrix is the gate's and not one handler's.
//!
//! Red-first: with the `refuse_on_browse` call removed from `require`, every
//! `refuses!` line below passes its gate and this file goes red on the first
//! one. The "still answers" lines are the other half: a refusal that also
//! blocked `Mirror.ReplicationStatus` would leave the operator blind on the
//! box they most need to see, and one that blocked the primary's `Repos.Delete`
//! would leave private data on the public-facing replica.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use gunnar_grpc::auth::audited_methods;
use gunnar_grpc::{
AccessService, Caller, ControlPlane, EventBus, MemoryControlPlane, MirrorService, PullsService,
ReposService, ServerConfig, ServiceContext, BROWSE_STILL_ANSWERS,
};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::mirror_server::Mirror as _;
use gunnar_proto::pull_requests_server::PullRequests as _;
use gunnar_proto::repos_server::Repos as _;
use gunnar_proto::*;
use tonic::{Code, Request};
const NS: &str = "nordisk";
const REPO: &str = "gunnar";
struct Rig {
plane: Arc<MemoryControlPlane>,
ctx: ServiceContext,
browse: Arc<AtomicBool>,
}
impl Rig {
fn new() -> Rig {
let plane = Arc::new(MemoryControlPlane::with_repo(NS, REPO));
let control: Arc<dyn ControlPlane> = plane.clone();
let browse = Arc::new(AtomicBool::new(false));
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(64)),
ServerConfig::default(),
)
.with_browse_flag(Arc::clone(&browse));
Rig { plane, ctx, browse }
}
/// A server-wide admin, as the operator's key is.
fn admin(&self, name: &str, kind: PrincipalKind) -> Caller {
let principal = self
.plane
.add_principal(name, name, kind)
.expect("principal added");
self.plane.grant_server(&principal.id, GrantLevel::Admin);
Caller {
principal_id: principal.id,
fingerprint: format!("SHA256:{name}"),
machine: kind == PrincipalKind::Machine,
authenticated: true,
}
}
fn authed<T>(&self, caller: &Caller, message: T) -> Request<T> {
let mut request = Request::new(message);
request.extensions_mut().insert(caller.clone());
request
}
}
fn code_of<T>(result: Result<T, tonic::Status>) -> Option<Code> {
result.err().map(|status| status.code())
}
/// The refusal is `FAILED_PRECONDITION`, names the box, and lifts the instant
/// the flag drops — the same admin key, the same request, both answers.
#[test]
fn a_browse_replica_refuses_a_mutator_by_precondition_and_a_standby_does_not() {
let rig = Rig::new();
let admin = rig.admin("operator", PrincipalKind::User);
let repos = ReposService::new(rig.ctx.clone());
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.expect("runtime");
let create = || {
rig.authed(
&admin,
CreateRepoRequest {
path: Some(RepoPath::new(NS, "new")),
..Default::default()
},
)
};
rig.browse.store(true, Ordering::SeqCst);
let refused = rt
.block_on(repos.create(create()))
.expect_err("a browse replica must refuse Repos.Create");
assert_eq!(refused.code(), Code::FailedPrecondition, "{refused:?}");
assert!(
refused.message().contains("browse replica") && refused.message().contains("Repos.Create"),
"the refusal names the box and the method: {}",
refused.message()
);
rig.browse.store(false, Ordering::SeqCst);
rt.block_on(repos.create(create()))
.expect("the same request on a non-browse box passes the gate");
}
/// The matrix: every Admin-level write is refused; the Admin-level reads and
/// `Mirror.SetRole` still answer; `Repos.Delete` and `Repos.Create` answer a
/// MACHINE and refuse a person.
#[test]
fn the_browse_matrix_reads_answer_writes_refuse_and_delete_is_the_machines_alone() {
let rig = Rig::new();
let person = rig.admin("operator", PrincipalKind::User);
let machine = rig.admin("primary", PrincipalKind::Machine);
rig.browse.store(true, Ordering::SeqCst);
let repos = ReposService::new(rig.ctx.clone());
let access = AccessService::new(rig.ctx.clone());
let mirror = MirrorService::new(rig.ctx.clone());
let pulls = PullsService::new(rig.ctx.clone());
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.expect("runtime");
// Writes: refused, whoever asks.
for caller in [&person, &machine] {
assert_eq!(
code_of(rt.block_on(repos.create_namespace(rig.authed(
caller,
CreateNamespaceRequest {
name: "x".into(),
..Default::default()
},
)))),
Some(Code::FailedPrecondition),
"Repos.CreateNamespace by {}",
caller.principal_id
);
assert_eq!(
code_of(rt.block_on(access.add_principal(rig.authed(
caller,
AddPrincipalRequest {
name: "someone".into(),
kind: PrincipalKind::User as i32,
id: String::new(),
},
)))),
Some(Code::FailedPrecondition),
"Access.AddPrincipal by {}",
caller.principal_id
);
assert_eq!(
code_of(rt.block_on(
mirror.set_replication(rig.authed(caller, SetReplicationRequest { target: None },))
)),
Some(Code::FailedPrecondition),
"Mirror.SetReplication by {}",
caller.principal_id
);
assert_eq!(
code_of(rt.block_on(
mirror.abdicate_replication(rig.authed(caller, AbdicateReplicationRequest {},))
)),
Some(Code::FailedPrecondition),
"Mirror.AbdicateReplication by {}",
caller.principal_id
);
// **GUN-010 says this one is not covered. It is** — and now it is
// pinned rather than argued. The review read `refuse_on_browse` as a
// check the merge path skips; the merge path calls
// `ServiceContext::require` like every other handler, `require` calls
// `refuse_on_browse` first, `PullRequests.Merge` is not in
// `BROWSE_STILL_ANSWERS`, and the level is `Write`. So a merge on a
// browse box is `FAILED_PRECONDITION` before the seam is reached —
// which is also why the row is here, in the matrix, and not in a test
// of its own: the property belongs to the gate, not to the verb.
assert_eq!(
code_of(rt.block_on(pulls.merge(rig.authed(
caller,
MergePullRequestRequest {
repo: Some(RepoPath::new(NS, REPO)),
number: 1,
..Default::default()
},
)))),
Some(Code::FailedPrecondition),
"PullRequests.Merge by {}",
caller.principal_id
);
}
// Reads at Admin level: not refused by the browse gate. (The in-memory
// plane has no replication, so ReplicationStatus answers Unimplemented —
// the point is that it is NOT FailedPrecondition.)
assert_ne!(
code_of(rt.block_on(
access.list_principals(rig.authed(&person, ListPrincipalsRequest::default(),))
)),
Some(Code::FailedPrecondition),
"Access.ListPrincipals must still answer on a browse box"
);
assert_ne!(
code_of(rt.block_on(mirror.replication_status(rig.authed(
&person,
ReplicationStatusRequest {
include_refs: false
},
)))),
Some(Code::FailedPrecondition),
"Mirror.ReplicationStatus must still answer on a browse box"
);
// SetRole passes the gate (the control plane decides PRIMARY vs STANDBY).
assert_ne!(
code_of(rt.block_on(mirror.set_role(rig.authed(
&person,
SetRoleRequest {
role: ReplicationRole::Standby as i32,
},
)))),
Some(Code::FailedPrecondition),
"Mirror.SetRole must reach the control plane on a browse box"
);
// Repos.Delete: the machine's alone.
assert_eq!(
code_of(rt.block_on(repos.delete(rig.authed(
&person,
DeleteRepoRequest {
path: Some(RepoPath::new(NS, REPO)),
purge: true,
..Default::default()
},
)))),
Some(Code::FailedPrecondition),
"a PERSON's admin key cannot delete on a browse box"
);
rt.block_on(repos.delete(rig.authed(
&machine,
DeleteRepoRequest {
path: Some(RepoPath::new(NS, REPO)),
purge: true,
..Default::default()
},
)))
.expect("the primary's MACHINE deletes on a browse box: that is the forget path");
// Repos.Create: the machine's alone, likewise (2026-09-22) — the forget's
// other half. A repository the browse box purged comes back by a push,
// and a push-create is sha1 by construction, so the primary's replicator
// pre-creates a sha256 repository here in its own format before pushing.
let create = |caller: &Caller| {
rig.authed(
caller,
CreateRepoRequest {
path: Some(RepoPath::new(NS, "returned")),
hash_kind: HashKind::Sha256 as i32,
visibility: Visibility::Public as i32,
..Default::default()
},
)
};
assert_eq!(
code_of(rt.block_on(repos.create(create(&person)))),
Some(Code::FailedPrecondition),
"a PERSON's admin key cannot create on a browse box"
);
rt.block_on(repos.create(create(&machine))).expect(
"the primary's MACHINE creates on a browse box: that is the pre-create before a push",
);
}
/// The allowlist names only methods that exist, so a rename cannot silently
/// turn a "still answers" into a refusal.
///
/// **The carve-out this used to carry is gone (2026-09-11, GUN-023 /
/// GUN-024).** On 2026-09-04 three Admin-level READS —
/// `Access.ListGroupMembers`, `Access.ListGroupMembershipEvents`,
/// `Webhooks.ListWebhooks` — were found audited by their handlers
/// (`service/access.rs`, `service/webhook.rs`) and absent from
/// `gunnar_grpc::auth::METHODS`, so the deny-by-default sweep in
/// `tests/auth.rs` never drove them. They were listed here by name with an
/// INVERTED assertion — they had to stay absent — precisely so that the day
/// METHODS was fixed this list would have to shrink rather than quietly become
/// a second census.
///
/// That day came, and the inverted assertion is what announced it: with the
/// census derived from the `.proto` files it turned out to be missing twelve
/// RPCs and not three, all three of those among them. So the list below is
/// gone rather than shorter, the inversion has nothing left to hold, and every
/// method in `BROWSE_STILL_ANSWERS` is now simply required to be IN the census
/// — the one assertion this test always wanted to be.
#[test]
fn every_method_the_browse_replica_still_answers_is_a_real_method() {
let census: std::collections::BTreeSet<&str> = audited_methods().into_iter().collect();
for method in BROWSE_STILL_ANSWERS {
assert!(
census.contains(method),
"{method} is in BROWSE_STILL_ANSWERS but no handler audits under that name"
);
}
}
/// **T21 (owner ruling 2026-09-22): a TWIN takes its own data-set growth exactly as
/// main does.** A twin is frozen like a browse replica, so without the twin clause
/// `Admin.GrowStart` / `GrowResume` / `GrowStatus` were refused there — which left
/// stop → resize → start as the only way to grow it. They pass the browse gate on a
/// twin and ONLY on a twin: a public browse box still refuses them.
#[test]
fn a_twin_answers_its_own_growth_and_a_browse_replica_still_refuses_it() {
use gunnar_grpc::AdminService;
use gunnar_proto::admin_server::Admin as _;
let rig = Rig::new();
let admin = rig.admin("monetize", PrincipalKind::Machine);
let svc = AdminService::new(rig.ctx.clone());
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.expect("runtime");
let start = || {
rig.authed(
&admin,
GrowStartRequest {
target_sectors_per_member: 60 << 21,
go_ahead: b"{}".to_vec(),
..Default::default()
},
)
};
let resume = || rig.authed(&admin, GrowResumeRequest { give_up: false });
rig.browse.store(true, Ordering::SeqCst);
let refused = rt
.block_on(svc.grow_start(start()))
.expect_err("a browse replica refuses a growth");
assert_eq!(refused.code(), Code::FailedPrecondition, "{refused:?}");
assert!(
refused.message().contains("browse replica"),
"{}",
refused.message()
);
assert_eq!(
code_of(rt.block_on(svc.grow_resume(resume()))),
Some(Code::FailedPrecondition)
);
rig.plane.set_twin(true);
// Past the gate: the in-memory plane has no engine, and says so — which is the
// HANDLER answering, not the browse refusal.
let past = rt
.block_on(svc.grow_start(start()))
.expect_err("the double has no engine");
assert_ne!(
past.code(),
Code::FailedPrecondition,
"a twin must not be refused its own growth as a browse box: {past:?}"
);
assert!(
!past.message().contains("browse replica"),
"{}",
past.message()
);
assert!(
rt.block_on(svc.grow_status(rig.authed(&admin, GrowStatusRequest {})))
.is_ok(),
"a twin answers its growth status"
);
assert_ne!(
code_of(rt.block_on(svc.grow_resume(resume()))),
Some(Code::FailedPrecondition)
);
}