crates/gunnar-policy/tests/protection.rs
//! Branch protection in detail, and the ancestry walk underneath it.
//!
//! `tests/gates.rs` proves the gate refuses and allows. This file proves the
//! *reason* it does: that the force-push check is a real graph walk and not one
//! of the four plausible impostors listed in `crate::gates::protection`.
mod common;
use common::{expect_allow, expect_reject, mint_key, refname, MergeGraph, Repo, Scenario};
use gunnar_policy::ancestry::{is_descendant_of, DEFAULT_ANCESTRY_BUDGET};
use gunnar_policy::{
BranchProtectionGate, Error, Gate, ProtectionRule, PushContext, RefUpdate, Verdict,
};
const BUDGET: usize = DEFAULT_ANCESTRY_BUDGET;
#[test]
fn ancestry_follows_every_parent_edge_not_just_the_first() {
// THE discriminating case. `m` is a merge of `b` and `f2`; `f2` is reachable
// from `m` only through the SECOND parent. A first-parent walk, which is
// indistinguishable from a correct one on any linear fixture, answers
// `false` here and turns a legitimate fast-forward into a force-push.
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let objects = &graph.repo.objects;
assert!(
is_descendant_of(objects, &graph.m, &graph.f2, BUDGET).unwrap(),
"the old tip sits on the merge's second parent and must still be found"
);
assert!(
is_descendant_of(objects, &graph.m, &graph.f1, BUDGET).unwrap(),
"and so must its parent"
);
assert!(is_descendant_of(objects, &graph.m, &graph.b, BUDGET).unwrap());
assert!(is_descendant_of(objects, &graph.n, &graph.f2, BUDGET).unwrap());
}
#[test]
fn ancestry_is_directional_and_says_no_when_it_means_no() {
// The identity-value guard: a walk that returned `true` unconditionally would
// pass the test above. These are the cases that must come back `false`.
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let objects = &graph.repo.objects;
assert!(!is_descendant_of(objects, &graph.f2, &graph.b, BUDGET).unwrap());
assert!(!is_descendant_of(objects, &graph.b, &graph.f2, BUDGET).unwrap());
assert!(!is_descendant_of(objects, &graph.m, &graph.n, BUDGET).unwrap());
assert!(!is_descendant_of(objects, &graph.a, &graph.b, BUDGET).unwrap());
}
#[test]
fn a_commit_is_its_own_ancestor_so_a_ref_that_does_not_move_is_not_a_force_push() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
assert!(is_descendant_of(&graph.repo.objects, &graph.m, &graph.m, BUDGET).unwrap());
}
#[test]
fn ancestry_works_the_same_on_a_sha256_repository() {
// The verification rule: the sha256 path is exercised by a sha256 fixture, never inferred
// from a green sha1 run. The oids are 32 bytes here and the commit headers
// are 64 hex characters wide, which is exactly the kind of thing a
// length-assuming parser gets wrong.
let graph = MergeGraph::build(gix_hash::Kind::Sha256);
assert_eq!(graph.m.kind(), gix_hash::Kind::Sha256);
assert_eq!(graph.m.as_bytes().len(), 32);
let objects = &graph.repo.objects;
assert!(is_descendant_of(objects, &graph.m, &graph.f2, BUDGET).unwrap());
assert!(!is_descendant_of(objects, &graph.f2, &graph.m, BUDGET).unwrap());
let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()]);
let good = Scenario::new(objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/main"),
graph.f2,
graph.m,
))
.introducing(graph.all());
expect_allow(&gate, &good.context()).unwrap();
let bad = Scenario::new(objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/main"),
graph.b,
graph.f2,
))
.introducing(graph.all());
expect_reject(&gate, &bad.context(), "non-fast-forward").unwrap();
}
#[test]
fn a_missing_commit_makes_the_answer_undecided_not_false() {
// A store that has forgotten a commit must not silently answer "not a
// descendant"; that would turn a storage fault into a policy verdict.
let repo = Repo::sha1();
let root = repo.commit("root", &[]);
let orphan_parent = repo.commit("gone", &[]);
let tip = repo.commit("tip", &[orphan_parent]);
// A second, empty store: the commits exist in `repo`, not here.
let empty = Repo::sha1();
let err = is_descendant_of(&empty.objects, &tip, &root, BUDGET).unwrap_err();
assert!(
matches!(err, Error::MissingObject { .. }),
"expected MissingObject, got {err}"
);
}
#[test]
fn an_exhausted_budget_is_an_error_not_a_verdict() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let err = is_descendant_of(&graph.repo.objects, &graph.n, &graph.root, 2).unwrap_err();
assert!(
matches!(err, Error::AncestryBudget { budget: 2, .. }),
"expected AncestryBudget, got {err}"
);
}
#[test]
fn the_first_matching_rule_wins() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let gate = BranchProtectionGate::new(vec![
ProtectionRule {
allow_force_push: true,
..ProtectionRule::protect("refs/heads/scratch").unwrap()
},
ProtectionRule::protect("refs/heads/**").unwrap(),
]);
// `refs/heads/scratch` hits the permissive rule first…
let permitted = Scenario::new(graph.repo.objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/scratch"),
graph.b,
graph.f2,
))
.introducing(graph.all());
expect_allow(&gate, &permitted.context()).unwrap();
// …while everything else falls through to the strict one.
let refused = Scenario::new(graph.repo.objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/main"),
graph.b,
graph.f2,
))
.introducing(graph.all());
expect_reject(&gate, &refused.context(), "non-fast-forward").unwrap();
}
#[test]
fn creating_a_branch_under_a_protection_pattern_is_allowed_by_default_but_can_be_refused() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let update = RefUpdate::create(refname("refs/heads/feature"), graph.n);
let default =
BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/**").unwrap()]);
let scenario = Scenario::new(graph.repo.objects.clone())
.pushing(update.clone())
.introducing(graph.all());
expect_allow(&default, &scenario.context()).unwrap();
let strict = BranchProtectionGate::new(vec![ProtectionRule {
allow_create: false,
..ProtectionRule::protect("refs/heads/**").unwrap()
}]);
expect_reject(&strict, &scenario.context(), "may not be created").unwrap();
}
#[test]
fn deleting_is_allowed_only_when_the_rule_says_so() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let scenario = Scenario::new(graph.repo.objects.clone())
.pushing(RefUpdate::delete(refname("refs/heads/main"), graph.n));
let strict =
BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()]);
expect_reject(&strict, &scenario.context(), "may not be deleted").unwrap();
let permissive = BranchProtectionGate::new(vec![ProtectionRule {
allow_delete: true,
..ProtectionRule::protect("refs/heads/main").unwrap()
}]);
expect_allow(&permissive, &scenario.context()).unwrap();
}
#[test]
fn the_allowed_principal_list_lets_the_right_principal_through() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main")
.unwrap()
.by(["release-bot"])]);
let allowed = Scenario::new(graph.repo.objects.clone())
.pushed_by("release-bot")
.pushing(RefUpdate::update(
refname("refs/heads/main"),
graph.m,
graph.n,
))
.introducing(graph.all());
expect_allow(&gate, &allowed.context()).unwrap();
let refused = Scenario::new(graph.repo.objects.clone())
.pushed_by("alice")
.pushing(RefUpdate::update(
refname("refs/heads/main"),
graph.m,
graph.n,
))
.introducing(graph.all());
expect_reject(
&gate,
&refused.context(),
"not among its allowed principals",
)
.unwrap();
}
#[test]
fn require_signature_on_a_protected_ref_checks_only_the_commits_this_push_adds() {
// The pre-existing history is unsigned. Only what the push introduces has to
// be signed. Otherwise turning the rule on would make the next push
// impossible, and the obvious "fix" is to check nothing at all.
let key = mint_key();
let repo = Repo::sha1();
let old_root = repo.commit("pre-existing, unsigned", &[]);
let added = repo.signed_commit("added, signed", &[old_root], &key);
let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main")
.unwrap()
.requiring_signature()]);
let good = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/main"),
old_root,
added,
))
.introducing([added]) // only `added` came in this pack
.with_key("alice", &key);
expect_allow(&gate, &good.context()).unwrap();
// …and an unsigned addition on the same base is refused.
let unsigned = repo.commit("added, unsigned", &[old_root]);
let bad = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/main"),
old_root,
unsigned,
))
.introducing([unsigned])
.with_key("alice", &key);
expect_reject(&gate, &bad.context(), "requires signed commits").unwrap();
}
#[test]
fn a_reference_with_no_matching_rule_is_not_the_gates_business() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()]);
let scenario = Scenario::new(graph.repo.objects.clone())
.pushing(RefUpdate::delete(refname("refs/heads/wip"), graph.n));
expect_allow(&gate, &scenario.context()).unwrap();
}
#[test]
fn a_command_with_neither_an_old_nor_a_new_value_is_refused() {
// The wire spells both as the null oid, so this is what a malformed command
// line decodes to. It must not fall through any branch as a no-op.
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let null = gix_hash::Kind::Sha1.null();
let update = RefUpdate::from_wire(refname("refs/heads/main"), null, null);
assert_eq!(update.kind(), gunnar_policy::UpdateKind::Noop);
let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()]);
let scenario = Scenario::new(graph.repo.objects.clone()).pushing(update);
expect_reject(&gate, &scenario.context(), "neither an old nor a new value").unwrap();
}
#[test]
fn the_null_oid_is_decoded_once_and_never_leaks_into_a_walk() {
let null = gix_hash::Kind::Sha1.null();
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let create = RefUpdate::from_wire(refname("refs/heads/x"), null, graph.n);
assert_eq!(create.old, None);
assert_eq!(create.new, Some(graph.n));
assert_eq!(create.kind(), gunnar_policy::UpdateKind::Create);
let delete = RefUpdate::from_wire(refname("refs/heads/x"), graph.n, null);
assert_eq!(delete.new, None);
assert!(delete.is_delete());
}
#[test]
fn the_gate_name_is_stable_because_events_and_reports_carry_it() {
let gate = BranchProtectionGate::new(Vec::new());
assert_eq!(gate.name(), "branch-protection");
let empty = Scenario::new(Repo::sha1().objects);
let ctx: PushContext<'_> = empty.context();
assert_eq!(gate.check(&ctx).unwrap(), Verdict::Allow);
}