crates/gunnar-policy/tests/push_options.rs
//! `git push -o …` reaches a gate, and the gate can say no.
//!
//! # The defect this witnesses
//!
//! `push-options` was advertised at `gunnar-wire/src/caps.rs`, parsed at
//! `gunnar-wire/src/receive_pack.rs`, and then discarded. The only consumer of
//! a parsed push option anywhere in the tree was `.len()`, in one `tracing`
//! field in `gunnar-cli/src/server/transport.rs`. `PushFacts` — the struct that
//! exists precisely so a gate can see what a push carried — had no options
//! field at all, so no policy could act on a push option even in principle.
//!
//! A client running `git push -o ci.skip` was therefore told on the wire that
//! the server speaks push options, and the option went nowhere. That is a wrong
//! answer on the wire, not a missing feature.
//!
//! # What is asserted
//!
//! Both directions of both rules, plus the undecidable case — and, in
//! `crates/gunnar-wire/tests/protocol.rs`, that a real
//! `ReceivePack::serve` hands the parsed options to the gate rather than only
//! to the outcome. A gate that can refuse but never receives anything is the
//! same defect with more code in it.
mod common;
use common::{expect_allow, expect_reject, refname, MergeGraph, Scenario};
use gunnar_policy::{Gate, PolicyConfig, PolicyEngine, PushOptionPolicy, PushOptionsGate, Verdict};
fn scenario(graph: &MergeGraph) -> Scenario {
Scenario::new(graph.repo.objects.clone())
.pushing(gunnar_policy::RefUpdate::update(
refname("refs/heads/main"),
graph.b,
graph.f2,
))
.introducing(graph.all())
}
#[test]
fn an_option_outside_the_allowlist_is_refused_and_one_inside_it_is_not() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let gate = PushOptionsGate::new(
PushOptionPolicy::new(["ci.*", "reviewer=*"], Vec::<String>::new()).unwrap(),
);
// ── red ────────────────────────────────────────────────────────────────
let sneaky = scenario(&graph).with_push_options(["ci.skip", "rm-rf=/"]);
expect_reject(&gate, &sneaky.context(), "\"rm-rf=/\" is not allowed here").unwrap();
// ── green, and for the right reason: the allowed ones really pass ──────
let fine = scenario(&graph).with_push_options(["ci.skip", "reviewer=alice"]);
expect_allow(&gate, &fine.context()).unwrap();
// An empty option list is not an allowlist violation: there is nothing to
// violate. This is the line that keeps the gate from refusing every
// ordinary push the moment an allowlist is configured.
let none = scenario(&graph).with_push_options(Vec::<String>::new());
expect_allow(&gate, &none.context()).unwrap();
// The glob is the shared `RefPattern` matcher, so `*` does not cross `/`.
let crossing = scenario(&graph).with_push_options(["ci.skip/and/more"]);
expect_reject(&gate, &crossing.context(), "is not allowed here").unwrap();
}
#[test]
fn a_required_option_that_is_missing_is_refused_and_one_that_is_present_is_not() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let gate = PushOptionsGate::new(
PushOptionPolicy::new(Vec::<String>::new(), ["ticket=NORD-*"]).unwrap(),
);
// ── red: no options at all ─────────────────────────────────────────────
let bare = scenario(&graph).with_push_options(Vec::<String>::new());
expect_reject(&gate, &bare.context(), "requires a push option matching").unwrap();
// ── red: options, but not the one demanded ─────────────────────────────
let wrong = scenario(&graph).with_push_options(["ci.skip"]);
let verdict = expect_reject(&gate, &wrong.context(), "ticket=NORD-*").unwrap();
assert!(
verdict.reason().unwrap_or_default().contains("\"ci.skip\""),
"the refusal must name what WAS sent, or an operator cannot tell a typo from an \
omission: {verdict:?}"
);
// ── green ──────────────────────────────────────────────────────────────
let ok = scenario(&graph).with_push_options(["ci.skip", "ticket=NORD-1"]);
expect_allow(&gate, &ok.context()).unwrap();
}
/// The undecidable case, which is the shape this whole gate was born out of.
///
/// A context whose `push_options` is `None` means nobody wired the options
/// through. If the gate treated that as "the client sent none" it would be
/// satisfied by its own plumbing being missing — which is exactly the state the
/// server shipped in — and an allowlist would find nothing to object to.
#[test]
fn a_context_that_never_saw_the_options_is_refused_rather_than_allowed() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let unwired = scenario(&graph); // no `.with_push_options(…)` at all
assert!(
unwired.context().push_options.is_none(),
"this test is about the unwired context; if it is Some, the premise is gone"
);
for gate in [
PushOptionsGate::new(PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap()),
PushOptionsGate::new(PushOptionPolicy::new(Vec::<String>::new(), ["ci.*"]).unwrap()),
] {
expect_reject(&gate, &unwired.context(), "carried no push-option report").unwrap();
}
// And the contrast that makes the assertion mean something: the SAME gate
// over an explicitly-empty list allows.
let measured_none = scenario(&graph).with_push_options(Vec::<String>::new());
expect_allow(
&PushOptionsGate::new(PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap()),
&measured_none.context(),
)
.unwrap();
}
/// An unconfigured policy builds **no gate**, rather than a gate that says yes.
///
/// The difference is visible in `gate_names`, so "is this repository refusing
/// on push options" is answerable — and a gate that always allowed would be
/// indistinguishable from a broken one in every test above.
#[test]
fn an_unconfigured_push_option_policy_contributes_no_gate() {
assert!(PushOptionPolicy::default().is_empty());
let engine = PolicyEngine::from_config(&PolicyConfig::default());
assert!(!engine.gate_names().contains(&"push_options"));
let configured = PolicyEngine::from_config(&PolicyConfig {
push_options: PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap(),
..PolicyConfig::default()
});
// `ai-trailers` is the one gate every repository runs unconfigured.
assert_eq!(configured.gate_names(), vec!["push_options", "ai-trailers"]);
}
/// The policy survives the JSON it is stored as.
///
/// The policy file is what an operator edits; a field that round-trips to
/// nothing is a rule that silently stops applying.
#[test]
fn the_push_option_policy_round_trips_through_json() {
let config = PolicyConfig {
push_options: PushOptionPolicy::new(["ci.*"], ["ticket=NORD-*"]).unwrap(),
..PolicyConfig::default()
};
let json = config.to_json().unwrap();
assert!(
json.contains("ticket=NORD-*"),
"the required pattern did not reach the stored document:\n{json}"
);
assert_eq!(PolicyConfig::from_json(&json).unwrap(), config);
// A typo must be an error, not a rule that quietly does not apply.
assert!(PolicyConfig::from_json(r#"{"push_options":{"allowedd":["ci.*"]}}"#).is_err());
}
/// The engine runs it end to end, and names it as the refusing gate.
#[test]
fn the_engine_reports_which_gate_refused_the_push_option() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let engine = PolicyEngine::from_config(&PolicyConfig {
push_options: PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap(),
..PolicyConfig::default()
});
let bad = scenario(&graph).with_push_options(["definitely-not-allowed"]);
let decision = engine.evaluate(&bad.context());
assert!(!decision.is_allowed());
let first = decision.rejections.first().expect("a rejection");
assert_eq!(first.gate, "push_options", "{decision:?}");
let good = scenario(&graph).with_push_options(["ci.skip"]);
assert!(
engine.evaluate(&good.context()).is_allowed(),
"the engine must still let a legal option through, or the red above proves nothing"
);
}
/// The gate must be able to allow. A gate wired to reject everything satisfies
/// every red above and is worth nothing.
#[test]
fn the_gate_is_not_a_universal_no() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let gate =
PushOptionsGate::new(PushOptionPolicy::new(["ci.*", "reviewer=*"], ["ci.*"]).unwrap());
let ok = scenario(&graph).with_push_options(["ci.skip", "reviewer=x"]);
assert_eq!(gate.check(&ok.context()).unwrap(), Verdict::Allow);
assert_eq!(gate.name(), "push_options");
}