crates/gunnar-cli/tests/policy_fails_closed.rs
#![cfg(feature = "server")]
//! **GUN-007: an unreadable policy refuses the push; it does not become an
//! empty one.**
//!
//! `load_policy` answered every failure with `PolicyConfig::default()` — a
//! document that did not parse, a control store that could not be read, a file
//! that could not be opened — and `PolicyGate::check` lets an empty engine
//! through, because "nothing is configured" is a real and common answer. So a
//! typo in a policy document switched branch protection and `require_signature`
//! **off** for that push, at `error!` level in a log nobody reads while a push
//! is in flight. Degraded operation was indistinguishable from full operation.
//!
//! The old argument for it was that refusing would take the repository offline.
//! It does not, and this file measures that too: the refusal is at the
//! **pre-receive** gate, so a clone of the same repository, in the same state,
//! still works.
mod common;
use std::path::{Path, PathBuf};
use common::{tools_present, Harness};
use gunnar_core::HashKind;
const NS: &str = "team";
const REPO: &str = "project";
fn policy_path(harness: &Harness) -> PathBuf {
harness
.git_dir(NS, REPO)
.join(gunnar_cli::server::transport::POLICY_FILE)
}
/// A local repository with one commit, pushed once so the fixture is real.
fn seeded(harness: &Harness) -> PathBuf {
let work = harness.dir.path().join("work");
harness
.git(
harness.dir.path(),
&[
"init",
"--quiet",
"-b",
"main",
work.to_str().expect("utf-8"),
],
)
.expect_ok("git init");
std::fs::write(work.join("a.txt"), "base\n").expect("write");
harness.git(&work, &["add", "."]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "-m", "base"])
.expect_ok("commit");
harness
.git(&work, &["push", "--quiet", &harness.url(NS, REPO), "main"])
.expect_ok("the first push, with no policy at all");
work
}
fn commit(harness: &Harness, work: &Path, text: &str) {
std::fs::write(work.join("a.txt"), text).expect("write");
harness.git(work, &["add", "."]).expect_ok("add");
harness
.git(work, &["commit", "--quiet", "-m", text])
.expect_ok("commit");
}
/// **The finding.** A policy document that does not parse refuses the push.
///
/// Mutation-proven 2026-09-11: make `load_policy` answer `Ok(default)` on a
/// parse error again and the push below SUCCEEDS — the gates the document
/// named are gone and nothing on the wire says so.
#[test]
fn a_policy_that_does_not_parse_refuses_the_push_rather_than_disabling_the_gates() {
assert!(tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NS, REPO, HashKind::Sha1);
let work = seeded(&harness);
let url = harness.url(NS, REPO);
// A document an operator meant to configure gates with, and mistyped.
std::fs::write(policy_path(&harness), "{ this is not json").expect("write the policy");
commit(&harness, &work, "second\n");
let refused = harness.git(&work, &["push", "--quiet", &url, "main"]);
assert!(
!refused.ok,
"a push was served while the repository's policy could not be read:\n{}",
refused.stderr
);
assert!(
refused.stderr.contains("could not read the policy"),
"the refusal must name the cause so an operator can fix it: {}",
refused.stderr
);
// **Reads are untouched.** This is the whole answer to "refusing would
// take the repository offline": it is the pre-receive gate, so it costs
// pushes and nothing else.
let clone = harness.dir.path().join("clone");
harness
.git(
harness.dir.path(),
&["clone", "--quiet", &url, clone.to_str().expect("utf-8")],
)
.expect_ok("a clone must still work while the policy is unreadable");
// The other direction, same repository, same push: with the document
// removed — nothing stored, no file, so nothing is configured — the push
// goes through. A gate that refused everything would pass the assertions
// above.
std::fs::remove_file(policy_path(&harness)).expect("remove the policy");
harness
.git(&work, &["push", "--quiet", &url, "main"])
.expect_ok("a repository with no policy at all must still take a push");
// …and a document that DOES parse is honoured rather than merely
// tolerated: `max_objects: 0` refuses a push that introduces anything, so
// the gate is demonstrably live rather than empty.
let config = gunnar_policy::PolicyConfig {
limits: gunnar_policy::LimitPolicy::objects(1),
..Default::default()
};
std::fs::write(
policy_path(&harness),
config.to_json().expect("render the policy"),
)
.expect("write the policy");
commit(&harness, &work, "third\n");
let refused = harness.git(&work, &["push", "--quiet", &url, "main"]);
assert!(
!refused.ok,
"a parseable policy must be applied, not merely accepted:\n{}",
refused.stderr
);
assert!(
!refused.stderr.contains("could not read the policy"),
"a parseable policy must be refused BY ITS GATE, not by the reader: {}",
refused.stderr
);
harness.stop();
}