crates/gunnar-cli/tests/push_events.rs
#![cfg(feature = "server")]
//! **The prize: a real `git push` over real SSH lighting up a real
//! `Events.Watch` gRPC subscriber**: the event stream and push attestation,
//! joined and measured.
//!
//! ```text
//! git ── ssh ──▶ russh ──▶ gunnar-wire receive-pack ──▶ gunnar-store
//! │
//! ├── EventBus.publish ──▶ Events.Watch ──▶ here
//! └── attestations.jsonl (attestation, on disk)
//! ```
//!
//! # Why this file exists at all
//!
//! Everything on the publish side could be present and dead, and nothing in the
//! suite would notice. `tests/ssh_end_to_end.rs` never starts the gRPC listener
//! (`no_grpc: true`); `gunnar-grpc/tests/event_bus.rs` publishes to the bus with
//! its own hand and reads it back. Between them was the join nobody crossed: no
//! test had ever asserted that *a push* produces *an event on a subscriber*. A
//! bus with no producer streams nothing and looks exactly like a quiet server,
//! which is the same failure a warehouse with zero rows had, for the same
//! reason.
//!
//! # Measured, never merely asserted — in four places
//!
//! * Every assertion here is on **delivered content**: repo, ref name, both
//! oids, the principal, the key fingerprint, the object count, the pack hash.
//! A test that subscribed and asserted "no error" would pass against exactly
//! the empty-surface bug this file exists to catch, because an empty stream
//! raises no error.
//! * The oids and the counts are compared against what **`git` on the client
//! says it pushed**, not against what gunnar reports elsewhere. Both halves
//! of gunnar agreeing with each other proves nothing.
//! * The pack hash is resolved to **bytes on the server's disk**: it is the
//! pushed pack's own trailing checksum, and the pack is appended verbatim
//! into the store, so the trailer is found INSIDE the store's blob file. A
//! constant, a truncated hash or an empty string fails.
//! * The refusal half is asserted too: a gate that says no must produce
//! `PushRejected` **naming the gate**, or a UI cannot tell a policy refusal
//! from a network fault.
//!
//! # The object list is a POLICY answer, and this file asserts both of them
//!
//! `PushAttestation.new_object_oids` is populated only when something asked for
//! it. On the engine gunnar ships, working out which objects a push introduced
//! means waiting for the indexer to absorb the pack (`oids_in_extent`), and
//! that wait is the whole cost the deferred indexer exists to avoid on the push
//! path — so `PushIngest` asks for the set only when the repository has a
//! policy gate (`want_objects = !gate.engine.is_empty()`).
//!
//! So there are two honest records, and asserting only one of them is how this
//! file went red for a day and stayed red:
//!
//! | repository | `new_object_count` | `new_object_oids` | `new_object_oids_unknown` |
//! |---|---|---|---|
//! | no gates | the pack's own count | empty | the sentence saying no gate asked |
//! | a gate | the enumerated length | the ids | empty |
//!
//! `a_real_git_push_over_ssh_reaches_a_live_events_watch_subscriber` and
//! `the_attestation_log_is_written_without_a_grpc_listener` assert the first
//! row; `a_gate_refused_push_emits_push_rejected_naming_the_gate` asserts the
//! second, on a repository that differs from the others by one policy document.
//! Either alone is satisfiable by a broken producer — the first by a gunnar
//! that never enumerates and always apologises, the second by one that never
//! learned to say when it had not — which is why both are here.
//!
//! It used to assert the ids unconditionally. That was written against the
//! packfile engine, which enumerated as a by-product of writing the `.idx`;
//! after the store moved it asserted a list nothing produced.
//!
//! The lag path (a slow subscriber getting a gap marker rather than unbounded
//! memory) is **not** re-tested here. It is owned by
//! `gunnar-grpc/tests/event_bus.rs`
//! (`a_subscriber_that_falls_behind_gets_a_gap_marker`,
//! `memory_is_bounded_by_the_ring_no_matter_how_far_behind`), which overflows a
//! real ring to get it. Twinning it behind an SSH connection would test the same
//! code with more ways to be flaky (one implementation).
mod common;
use std::time::Duration;
use common::{local_repo, tools_present, Harness, HarnessOptions};
use gunnar_core::HashKind;
use gunnar_proto::{event, Event, EventKind, WatchRequest};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
/// How long a test waits for the events one push should produce.
///
/// Generous: the push is a subprocess, the transport hands the channel through
/// `spawn_blocking`, and CI is slow. A wait that is too short turns into a
/// flake that reads exactly like the bug.
const WAIT: Duration = Duration::from_secs(20);
// ───────────────────────────────────────────────────────────────────────────
// a live Events.Watch subscriber
// ───────────────────────────────────────────────────────────────────────────
/// A `Events.Watch` stream, drained by a background task into a queue.
///
/// The drain task owns the `Streaming`, and therefore the TCP connection. It is
/// **aborted on drop, and waited for**; see the `Drop` impl, which is not
/// tidiness.
struct Subscriber {
events: UnboundedReceiver<Event>,
drain: Option<tokio::task::JoinHandle<()>>,
runtime: tokio::runtime::Handle,
}
impl Drop for Subscriber {
fn drop(&mut self) {
// A live watch stream keeps the gRPC connection open, and a connection
// that is open when `Server::shutdown` runs costs the whole grace
// period (`GRPC_SHUTDOWN_GRACE`) per test. Aborting is not enough on
// its own (`abort()` only schedules the cancellation), so the handle
// is awaited, which is what actually drops the `Streaming`.
//
// `Subscriber` is declared AFTER `Harness` in every test here, so it
// drops first and the connection is gone before the server is asked to
// stop.
if let Some(drain) = self.drain.take() {
drain.abort();
let _ = self.runtime.block_on(drain);
}
}
}
impl Subscriber {
/// Connect, subscribe, and **wait until the server has accepted the
/// subscription** before returning.
///
/// The wait is the point: a subscription that were still being set up when
/// the push started could miss the events and the test would be measuring a
/// race rather than the producer.
fn watch(harness: &Harness, request: WatchRequest) -> Subscriber {
let url = harness.grpc_url();
// The allow-all hole is closed: `Events.Watch` needs a server-scoped
// Read grant and an sshsig credential from the SAME key the harness
// pushes with.
let request = harness.authed("Events.Watch", request);
let (tx, events) = unbounded_channel();
let ready = harness.runtime.block_on(async move {
let channel = tonic::transport::Channel::from_shared(url)
.expect("a valid endpoint")
.connect()
.await
.expect("connect to the gRPC control plane");
let mut client = gunnar_proto::events_client::EventsClient::new(channel);
// `watch` returns once the server has built the stream, i.e. once
// `EventBus::subscribe` has been called, so everything published
// after this await is guaranteed to be in this subscriber's ring.
let stream = client
.watch(request)
.await
.expect("Events.Watch was refused")
.into_inner();
stream
});
let drain = harness.runtime.spawn(async move {
let mut stream = ready;
while let Some(item) = tokio_stream::StreamExt::next(&mut stream).await {
match item {
Ok(event) => {
if tx.send(event).is_err() {
break;
}
}
Err(status) => {
eprintln!("Events.Watch ended: {status}");
break;
}
}
}
});
Subscriber {
events,
drain: Some(drain),
runtime: harness.runtime.handle().clone(),
}
}
/// Collect events until `enough` is satisfied, or fail after [`WAIT`].
///
/// The failure message prints everything that *did* arrive, because "the
/// stream was empty" and "the stream carried the wrong thing" call for
/// completely different next steps and a bare timeout tells you neither.
fn collect_until(
&mut self,
harness: &Harness,
what: &str,
enough: impl Fn(&[Event]) -> bool,
) -> Vec<Event> {
let mut seen: Vec<Event> = Vec::new();
harness.runtime.block_on(async {
let deadline = tokio::time::Instant::now() + WAIT;
while !enough(&seen) {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, self.events.recv()).await {
Ok(Some(event)) => seen.push(event),
// The stream ended, or the wait ran out. Either way there is
// nothing more coming, so stop rather than spin.
Ok(None) | Err(_) => break,
}
}
});
assert!(
enough(&seen),
"waited {WAIT:?} for {what} and it never arrived on Events.Watch.\n\
{} event(s) were delivered: {}\n\
An EMPTY list here means the push produced no events at all. The producer is not \
publishing, and `Watch` is surface with nothing behind it.",
seen.len(),
summarise(&seen)
);
seen
}
}
fn summarise(events: &[Event]) -> String {
if events.is_empty() {
return "(none)".to_owned();
}
events
.iter()
.map(|e| match &e.payload {
Some(event::Payload::RefUpdated(u)) => {
format!("RefUpdated({} -> {})", u.ref_name, u.new_oid)
}
Some(event::Payload::RefDeleted(d)) => format!("RefDeleted({})", d.ref_name),
Some(event::Payload::PushRejected(r)) => {
format!("PushRejected({}, gate={:?})", r.ref_name, r.gate)
}
Some(event::Payload::PushAttestation(a)) => {
format!("PushAttestation({} refs)", a.ref_updates.len())
}
other => format!("{other:?}"),
})
.collect::<Vec<_>>()
.join(", ")
}
fn ref_updated(events: &[Event]) -> Option<&gunnar_proto::RefUpdated> {
events.iter().find_map(|e| match &e.payload {
Some(event::Payload::RefUpdated(u)) => Some(u),
_ => None,
})
}
fn attestation(events: &[Event]) -> Option<&gunnar_proto::PushAttestation> {
events.iter().find_map(|e| match &e.payload {
Some(event::Payload::PushAttestation(a)) => Some(a),
_ => None,
})
}
fn rejected(events: &[Event]) -> Option<&gunnar_proto::PushRejected> {
events.iter().find_map(|e| match &e.payload {
Some(event::Payload::PushRejected(r)) => Some(r),
_ => None,
})
}
/// The client key's SHA-256 fingerprint **according to `ssh-keygen`**.
///
/// An outside oracle on purpose. `harness.key_fingerprint` is read back out of
/// gunnar's own identity table, so comparing an event against it proves the
/// field is populated but not that it names the right key; both halves would
/// be wrong together. OpenSSH has no such stake.
fn ssh_keygen_fingerprint(harness: &Harness) -> String {
let out = std::process::Command::new("ssh-keygen")
.args(["-lf", harness.key_path.to_str().unwrap()])
.output()
.expect("ssh-keygen is on PATH");
assert!(
out.status.success(),
"ssh-keygen -lf failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout).into_owned();
text.split_whitespace()
.find(|word| word.starts_with("SHA256:"))
.unwrap_or_else(|| panic!("no SHA256 fingerprint in {text:?}"))
.to_owned()
}
/// `git rev-parse <spec>` in a working tree.
fn rev(harness: &Harness, at: &std::path::Path, spec: &str) -> String {
let out = harness.git(at, &["rev-parse", spec]);
out.expect_ok(&format!("git rev-parse {spec}"));
out.stdout.trim().to_owned()
}
// ───────────────────────────────────────────────────────────────────────────
// the headline
// ───────────────────────────────────────────────────────────────────────────
/// **A real push over SSH produces real events on a live `Events.Watch`
/// subscriber**, and every field is the one the push actually had.
#[test]
fn a_real_git_push_over_ssh_reaches_a_live_events_watch_subscriber() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let harness = Harness::start_with_wire_options(
"team",
"project",
HashKind::Sha1,
gunnar_cli::server::WireConfig {
// `--no-attest-objects`. The contract under test here is the one an
// operator opts into: enumeration waits on the indexer, so a server
// told not to pay for it must say the set is ABSENT rather than
// show an empty list. The shipped default is the opposite and is
// asserted by `the_default_attestation_enumerates_what_it_counted`.
attest_objects: false,
..Default::default()
},
HarnessOptions {
grpc: true,
..HarnessOptions::default()
},
);
// Subscribe FIRST. Everything below happens to a stream that is already
// open, which is the property the event stream exists for: a UI stops polling.
let mut sub = Subscriber::watch(
&harness,
WatchRequest {
// The attestation's object list is opt-in even for a subscriber that asked for
// attestations. Opted in here because this test asserts it; the
// opt-OUT direction is covered by
// gunnar-grpc/tests/event_bus.rs::attestation_object_ids_are_opt_in.
include_attestation_object_ids: true,
..Default::default()
},
);
let work = harness.dir.path().join("work");
let head = local_repo(&harness, &work, HashKind::Sha1, "events over the wire\n");
harness
.git(
&work,
&[
"push",
&harness.url("team", "project"),
"HEAD:refs/heads/main",
],
)
.expect_ok("git push to gunnar over ssh");
let events = sub.collect_until(&harness, "a RefUpdated and a PushAttestation", |seen| {
ref_updated(seen).is_some() && attestation(seen).is_some()
});
// ── the event stream: RefUpdated ────────────────────────────────────────
let updated = ref_updated(&events).expect("checked above");
let repo = updated
.repo
.as_ref()
.expect("RefUpdated must name its repo");
assert_eq!(repo.namespace, "team");
assert_eq!(repo.repo, "project");
assert_eq!(updated.ref_name, "refs/heads/main");
assert_eq!(
updated.old_oid,
"0".repeat(40),
"creating a branch must report the null oid as `old`, not an empty string, since a client \
cannot tell a create from a missing field otherwise"
);
assert_eq!(
updated.new_oid, head,
"the event's new oid is not the commit git pushed"
);
assert_eq!(updated.principal_id, "tester");
assert_eq!(
updated.key_fingerprint, harness.key_fingerprint,
"the event must name the key the push authenticated with"
);
assert_eq!(
updated.key_fingerprint,
ssh_keygen_fingerprint(&harness),
"the fingerprint on the event must be the one OpenSSH computes for the key `ssh` \
actually offered; gunnar agreeing with gunnar proves nothing"
);
assert!(!updated.forced, "an ordinary create is not a force-push");
// The envelope, not just the payload: a UI filters on `kind` and orders on
// `seq`, and both would be silently wrong if the payload alone were right.
let envelope = events
.iter()
.find(|e| matches!(e.payload, Some(event::Payload::RefUpdated(_))))
.expect("checked above");
assert_eq!(envelope.kind, EventKind::RefUpdated as i32);
assert!(envelope.seq > 0, "sequence numbers start at 1");
assert!(envelope.at_unix_ms > 0, "an event must be stamped");
// ── the attestation, on the stream ──────────────────────────────────────
let att = attestation(&events).expect("checked above");
let att_repo = att
.repo
.as_ref()
.expect("the attestation must name its repo");
assert_eq!(
(att_repo.namespace.as_str(), att_repo.repo.as_str()),
("team", "project")
);
assert_eq!(att.principal_id, "tester");
assert_eq!(att.principal_name, "tester");
assert_eq!(att.key_fingerprint, harness.key_fingerprint);
assert_eq!(att.hash_kind, gunnar_proto::HashKind::Sha1 as i32);
assert_eq!(att.transport, "ssh");
assert_eq!(att.server_agent, gunnar_proto::AGENT);
assert!(att.received_at_unix_ms > 0);
assert_eq!(
att.ref_updates.len(),
1,
"one ref moved; got {:?}",
att.ref_updates
);
assert_eq!(att.ref_updates[0].ref_name, "refs/heads/main");
assert_eq!(att.ref_updates[0].old_oid, "0".repeat(40));
assert_eq!(att.ref_updates[0].new_oid, head);
// ── the object set: the COUNT, and the reason there is no list ──────────
//
// This repository has no policy document, so it has no gates, so the ingest
// was never asked to enumerate the pushed objects — see the module header.
// Three things must therefore be true at once, and they are three different
// assertions because any two of them can hold while the third is the bug:
//
// 1. the count is what git pushed,
// 2. the list is empty,
// 3. the record SAYS the list is empty because nobody enumerated it.
//
// Without (3) this record is indistinguishable from a push that introduced
// no objects at all, which is the state c4c391bb left behind: it made the
// count honest and left the empty list standing beside it saying nothing.
let pushed = harness.git(&work, &["rev-list", "--objects", "HEAD"]);
pushed.expect_ok("git rev-list --objects HEAD");
let pushed_by_git = pushed.stdout.lines().filter(|l| !l.is_empty()).count();
assert_eq!(
pushed_by_git, 3,
"the fixture is a commit, a tree and a blob; git says {pushed_by_git}, so this test is \
not measuring what it thinks it is"
);
assert_eq!(
att.new_object_count as usize, pushed_by_git,
"the attestation must count what GIT says it pushed. gunnar agreeing with gunnar proves \
nothing, so this is `git rev-list --objects HEAD` on the client"
);
assert!(
att.new_object_oids.is_empty(),
"no gate is configured for this repository, so nothing enumerated the pushed objects and \
the record must not carry a list: {:?}",
att.new_object_oids
);
assert!(
!att.new_object_oids_unknown.is_empty(),
"the object list is EMPTY and the record gives no reason. An empty list with no reason is \
a claim that this push introduced no objects, and it introduced {}. That is the \
fabricated value a measured assertion forbids",
att.new_object_count
);
assert!(
att.new_object_oids_unknown.contains("gate")
&& att.new_object_oids_unknown.contains("enumerated"),
"the reason must name WHY — no policy gate asked, so the set was not enumerated — or a \
reader cannot tell it from a failure: {:?}",
att.new_object_oids_unknown
);
// The other direction is asserted, on a repository that DOES have a gate,
// by `a_gate_refused_push_emits_push_rejected_naming_the_gate` below. Both
// are needed: this one alone would pass against a producer that had stopped
// enumerating altogether, and that one alone would pass against a producer
// that never learned to say it had not.
// ── the pack, resolved to bytes on the server's disk ────────────────────
//
// `objects/pack/pack-<id>.pack` is NOT where this lands. The znippy engine
// writes no packfile at all (`IndexedPack::data_path` is `None`, and
// `serve_receive_pack` says it out loud: *"No `objects/pack` is
// created."*): the pushed pack is appended VERBATIM into the store's one
// blob file. So the pack hash — the pack's own trailing checksum — is
// resolved to that file, by finding the trailer inside it. A constant, a
// truncated hash or a record about a push that never landed all fail here.
let store = harness
.git_dir("team", "project")
.join("gunnar-git.znippy")
.join("objects.pack");
let stored = std::fs::read(&store).unwrap_or_else(|err| {
panic!(
"the znippy store's blob file is not readable at {}: {err}",
store.display()
)
});
assert!(
att.new_object_bytes > 0 && (att.new_object_bytes as usize) <= stored.len(),
"new_object_bytes is {} and the store holds {} bytes; the pack was appended into it, so \
the first must be a real number no larger than the second",
att.new_object_bytes,
stored.len()
);
let trailer = gix_hash::ObjectId::from_hex(att.pack_hash.as_bytes())
.unwrap_or_else(|err| panic!("pack_hash {:?} is not an object id: {err}", att.pack_hash));
assert!(
stored
.windows(trailer.as_slice().len())
.any(|w| w == trailer.as_slice()),
"the attestation's pack_hash {} is nowhere in {} ({} bytes). It is the trailing checksum \
of the pack the store appended, so a record naming a pack the store does not hold is \
provenance for nothing",
att.pack_hash,
store.display(),
stored.len()
);
// ── the attestation, on disk ────────────────────────────────────────────
let log = harness.attestation_log();
let lines = log.read().expect("read the attestation log");
assert_eq!(
lines.len(),
1,
"exactly one push happened, so the log must hold exactly one line; it holds {}. The log \
is at {}",
lines.len(),
log.path().display()
);
let logged = &lines[0];
assert_eq!(
logged.new_object_oids, att.new_object_oids,
"the durable record and the event must carry the same object list. The log is the copy \
that survives, and a divergence makes both useless as provenance"
);
assert_eq!(
logged.new_object_oids_unknown, att.new_object_oids_unknown,
"…and the same reason for it being empty. The reason is the half that makes the list \
readable at all, so a log that dropped it would be the empty-list bug again, on the \
copy that outlives the process"
);
assert_eq!(logged.new_object_count, att.new_object_count);
assert_eq!(logged.pack_hash, att.pack_hash);
assert_eq!(logged.ref_updates, att.ref_updates);
assert_eq!(logged.key_fingerprint, harness.key_fingerprint);
assert_eq!(logged.attestation_id, att.attestation_id);
// Attestation's whole point: the record is a JSON line, and reading it needs no
// gunnar. Assert the raw bytes, not the decoded struct.
let raw = std::fs::read_to_string(log.path()).expect("read the raw log");
assert_eq!(raw.lines().count(), 1);
assert!(
raw.contains(&head) && raw.contains("\"transport\":\"ssh\""),
"the raw JSONL line must be self-describing: {raw}"
);
}
/// **The subscriber sees the push that follows it, and is not replayed the one
/// before it.**
///
/// `Events.Watch` is a notification channel, not a log (`EventBus::publish`
/// says so). Both halves matter: without the first, the stream is dead; without
/// the second, "it saw an event" could be satisfied by history rather than by
/// the push under test, and this file's headline would be provable by a
/// producer that had stopped working.
#[test]
fn a_subscriber_sees_the_push_that_follows_it_and_not_the_one_before() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let harness = Harness::start_with_grpc("team", "project", HashKind::Sha1);
let url = harness.url("team", "project");
let work = harness.dir.path().join("work");
// Push ONE, before anybody is watching.
let first = local_repo(&harness, &work, HashKind::Sha1, "before\n");
harness
.git(&work, &["push", &url, "HEAD:refs/heads/main"])
.expect_ok("the push nobody watched");
let mut sub = Subscriber::watch(&harness, WatchRequest::default());
// Push TWO, after.
std::fs::write(work.join("hello.txt"), "after\n").unwrap();
harness.git(&work, &["add", "hello.txt"]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "-m", "after"])
.expect_ok("commit");
let second = rev(&harness, &work, "HEAD");
assert_ne!(
first, second,
"the two pushes must differ or this proves nothing"
);
harness
.git(&work, &["push", &url, "HEAD:refs/heads/main"])
.expect_ok("the push that was watched");
let events = sub.collect_until(&harness, "a RefUpdated for the second push", |seen| {
ref_updated(seen).is_some()
});
let updated = ref_updated(&events).expect("checked above");
assert_eq!(
(updated.old_oid.as_str(), updated.new_oid.as_str()),
(first.as_str(), second.as_str()),
"the event must describe the SECOND push: old = the first commit, new = the second"
);
assert!(
!events.iter().any(|e| match &e.payload {
Some(event::Payload::RefUpdated(u)) => u.new_oid == first,
Some(event::Payload::PushAttestation(a)) =>
a.ref_updates.iter().any(|r| r.new_oid == first),
_ => false,
}),
"the stream replayed a push that happened before the subscription: {}",
summarise(&events)
);
}
/// **A gate-refused push emits `PushRejected`, naming the gate and the reason.**
///
/// `PushGate::check` may only answer `Err(String)`, so the name of the gate that
/// said no does not cross that boundary on its own. It is recorded on the way
/// past. A `gate` field left empty would still make a plausible-looking event
/// and a UI would show "rejected" with no way to say by what.
///
/// Both directions, in one test: the same rule that refuses the force-push is
/// seen to *allow* the create, or a gate wired to refuse everything would pass
/// the refusal assertion.
#[test]
fn a_gate_refused_push_emits_push_rejected_naming_the_gate() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let harness = Harness::start_with_grpc("team", "project", HashKind::Sha1);
let config = gunnar_policy::PolicyConfig {
protection: vec![gunnar_policy::ProtectionRule::protect("refs/heads/main").unwrap()],
..Default::default()
};
std::fs::write(
harness
.git_dir("team", "project")
.join(gunnar_cli::server::transport::POLICY_FILE),
config.to_json().unwrap(),
)
.expect("write the policy document");
let mut sub = Subscriber::watch(&harness, WatchRequest::default());
let work = harness.dir.path().join("work");
let created = local_repo(&harness, &work, HashKind::Sha1, "one\n");
let url = harness.url("team", "project");
harness
.git(&work, &["push", &url, "HEAD:refs/heads/main"])
.expect_ok("creating a protected branch is allowed");
// The allow half: the protected branch still produced a RefUpdated.
let allowed = sub.collect_until(&harness, "the accepted create", |seen| {
ref_updated(seen).is_some()
});
assert_eq!(ref_updated(&allowed).unwrap().new_oid, created);
assert!(
rejected(&allowed).is_none(),
"an accepted push must not emit PushRejected: {}",
summarise(&allowed)
);
// The refuse half: rewrite history and be told no.
harness
.git(&work, &["reset", "--hard", "HEAD~0"])
.expect_ok("reset");
std::fs::write(work.join("hello.txt"), "divergent\n").unwrap();
harness.git(&work, &["add", "hello.txt"]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "--amend", "-m", "divergent"])
.expect_ok("amend");
let divergent = rev(&harness, &work, "HEAD");
assert_ne!(
divergent, created,
"the amend must have rewritten the commit"
);
harness
.git(&work, &["push", "--force", &url, "HEAD:refs/heads/main"])
.expect_failure("a force-push onto a protected branch");
let events = sub.collect_until(&harness, "a PushRejected", |seen| rejected(seen).is_some());
let refusal = rejected(&events).expect("checked above");
let repo = refusal
.repo
.as_ref()
.expect("PushRejected must name its repo");
assert_eq!(
(repo.namespace.as_str(), repo.repo.as_str()),
("team", "project")
);
assert_eq!(refusal.ref_name, "refs/heads/main");
assert_eq!(
refusal.gate, "branch-protection",
"PushRejected must name the gate that refused; an empty `gate` leaves a UI unable to \
distinguish a policy refusal from a transport fault"
);
assert!(
refusal.reason.contains("branch-protection"),
"the reason must be the gate's own, not a generic one: {:?}",
refusal.reason
);
assert_eq!(refusal.principal_id, "tester");
// Nothing moved, so attestation has nothing to attest to and must not invent a
// record. A log line for a push that did not happen is worse than none.
assert!(
!events
.iter()
.any(|e| matches!(e.payload, Some(event::Payload::PushAttestation(_))))
|| attestation(&events).map(|a| a.ref_updates.len()) == Some(1),
"a wholly-refused push must not produce an attestation claiming refs moved: {}",
summarise(&events)
);
let logged = harness
.attestation_log()
.read()
.expect("read the attestation log");
assert_eq!(
logged.len(),
1,
"one push landed and one was refused, so the log must hold exactly the one that landed; \
it holds {:?}",
logged
.iter()
.map(|a| a.attestation_id.as_str())
.collect::<Vec<_>>()
);
assert_eq!(logged[0].ref_updates[0].new_oid, created);
// ── the OTHER direction of the object set (the headline test's pair) ────
//
// This repository HAS a gate, and that is exactly what makes the ingest ask
// the store for the pushed ids (`PushIngest::want_objects` is
// `!gate.engine.is_empty()`; enumerating waits for the indexer, which is
// why a gateless push does not pay for it). So the record that the headline
// test proves is honestly EMPTY must here be honestly FULL — same server,
// same engine, same code path, one policy document apart.
//
// Without this assertion the headline test would pass against a gunnar that
// had stopped enumerating objects altogether and only learned to apologise
// for it.
assert!(
logged[0].new_object_oids.contains(&created),
"a gate is configured, so the store WAS asked which objects the push introduced, and the \
pushed commit {created} must be in the list: {:?} (reason field: {:?})",
logged[0].new_object_oids,
logged[0].new_object_oids_unknown
);
assert!(
logged[0].new_object_oids_unknown.is_empty(),
"an enumerated set must leave the reason EMPTY, or a reader is told a list it can trust \
is untrustworthy: {:?}",
logged[0].new_object_oids_unknown
);
assert_eq!(
logged[0].new_object_count as usize,
logged[0].new_object_oids.len(),
"when the set IS enumerated the count is the list's length, or a subscriber that took \
the count and one that took the list are told different stories"
);
}
/// **A server with a live `Events.Watch` subscriber can still be stopped.**
///
/// Found by this file, and it is a real defect, not a test artefact:
/// `serve_with_incoming_shutdown` waits for in-flight requests, a
/// server-streaming RPC is in flight until the client goes away, and the event
/// stream's whole purpose is that a UI keeps one open. So `gunnar serve` with one panel
/// attached could not be stopped: Ctrl-C hung. Measured before the fix, a test
/// binary wedged for eleven minutes, connection still `ESTAB`, no CPU.
///
/// Both bounds are asserted. Under [`GRPC_SHUTDOWN_GRACE`] would mean the
/// stream was not actually open and the test proves nothing; far over it would
/// mean the bound is not being applied.
#[test]
fn a_server_with_a_live_watch_subscriber_still_shuts_down() {
use gunnar_cli::server::serve::GRPC_SHUTDOWN_GRACE;
let mut harness = Harness::start_with_grpc("team", "project", HashKind::Sha1);
// Held across the shutdown on purpose. Every other test drops its
// subscriber first; this one does not, which is the case that hung.
let sub = Subscriber::watch(&harness, WatchRequest::default());
let started = std::time::Instant::now();
harness.stop();
let took = started.elapsed();
assert!(
took >= GRPC_SHUTDOWN_GRACE,
"shutdown returned in {took:?}, faster than the {GRPC_SHUTDOWN_GRACE:?} grace period, \
the watch stream cannot have been open, so this test is not exercising the case it \
exists for"
);
assert!(
took < GRPC_SHUTDOWN_GRACE * 5,
"shutdown took {took:?} with one Events.Watch subscriber attached. The graceful wait is \
unbounded again: a server with a UI connected cannot be stopped."
);
drop(sub);
}
/// **The attestation log is written even with no gRPC listener at all.**
///
/// Attestation's two halves are not the same promise. The event stream explicitly does
/// not keep anything: with nobody watching, `EventBus::publish` drops the event
/// on the floor. The log is the durable half, and an appliance served with
/// `--no-grpc` must still produce provenance. If this were conditional on the
/// bus, the record would silently vanish in exactly the deployment that most
/// needs it.
#[test]
fn the_attestation_log_is_written_without_a_grpc_listener() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let harness = Harness::start_with_wire(
"team",
"project",
HashKind::Sha256,
gunnar_cli::server::WireConfig {
// `--no-attest-objects`. The contract under test here is the one an
// operator opts into: enumeration waits on the indexer, so a server
// told not to pay for it must say the set is ABSENT rather than
// show an empty list. The shipped default is the opposite and is
// asserted by `the_default_attestation_enumerates_what_it_counted`.
attest_objects: false,
..Default::default()
},
);
assert!(
harness.grpc_addr.is_none(),
"this test is about the no-gRPC case and the listener is up"
);
let work = harness.dir.path().join("work");
let head = local_repo(&harness, &work, HashKind::Sha256, "no grpc here\n");
harness
.git(
&work,
&[
"push",
&harness.url("team", "project"),
"HEAD:refs/heads/main",
],
)
.expect_ok("git push with no control plane running");
let logged = harness
.attestation_log()
.read()
.expect("read the attestation log");
assert_eq!(logged.len(), 1, "one push, one attestation line");
assert_eq!(logged[0].ref_updates[0].new_oid, head);
assert_eq!(
logged[0].hash_kind,
gunnar_proto::HashKind::Sha256 as i32,
"the attestation must carry the repository's real hash kind, not a default"
);
// The same honest contract as the headline test, on the durable half: this
// repository has no gates either, so the count is real, the list is empty
// and the record says which. Asserted here as well because the log is the
// copy an auditor reads with no gunnar running, and it is written by a
// different line than the event.
assert_eq!(
logged[0].new_object_count, 3,
"a commit, a tree and a blob went over the wire; the record counts {}",
logged[0].new_object_count
);
assert!(
logged[0].new_object_oids.is_empty()
&& logged[0].new_object_oids_unknown.contains("enumerated"),
"with no policy gate the ids are not worked out, and the line must SAY so rather than \
show an empty list: oids={:?} reason={:?}",
logged[0].new_object_oids,
logged[0].new_object_oids_unknown
);
}
/// **The shipped default enumerates what it counted**, and the reason field is
/// then empty.
///
/// The mirror of the two arms above, which run under `--no-attest-objects`.
/// Both halves have to be asserted or the flag is one behaviour with a switch
/// nobody proves does anything: a server that always enumerated would pass the
/// frugal arms' count assertions and fail only on the reason, and a server that
/// never did would pass this one's count and fail only on the list.
///
/// This is the half the roadmap's chain rests on. `gunnar verify`'s push link
/// asks *which attestation names this object as one it introduced*, and on a
/// repository with no policy gate — the shipped default — there has to BE a
/// list for it to ask about.
#[test]
fn the_default_attestation_enumerates_what_it_counted() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let harness = Harness::start("team", "project", HashKind::Sha256);
let work = harness.dir.path().join("work");
let head = local_repo(&harness, &work, HashKind::Sha256, "enumerated by default\n");
harness
.git(
&work,
&[
"push",
&harness.url("team", "project"),
"HEAD:refs/heads/main",
],
)
.expect_ok("push");
let logged = harness
.attestation_log()
.read()
.expect("read the attestation log");
assert_eq!(logged[0].ref_updates[0].new_oid, head);
assert_eq!(logged.len(), 1, "one push, one attestation: {logged:?}");
let att = &logged[0];
assert!(
att.new_object_oids_unknown.is_empty(),
"the default pays for enumeration, so there is no reason to record: {:?}",
att.new_object_oids_unknown
);
assert_eq!(
att.new_object_oids.len() as u64,
att.new_object_count,
"the record counted {} objects and listed {}; a count without the list it claims to \
match is the absence-rendered-as-a-value defect in its other direction",
att.new_object_count,
att.new_object_oids.len()
);
assert!(
att.new_object_count > 0,
"the fixture pushed nothing, so this arm proves nothing"
);
}
/// **A force-push is reported as forced, and a fast-forward is not**.
///
/// `RefUpdated.forced` was a hardcoded `false`, so every force-push on the
/// server was published as an ordinary one and no consumer of `Events.Watch`
/// could answer "who rewrote this branch". A missing field is a gap; a field
/// that always says `false` is a wrong answer, and a UI repeats it.
///
/// # Why both directions are in one test
///
/// Asserting only `forced == false` on a fast-forward is the identity-value
/// trap in its purest form: it passes against the constant this entry is about.
/// Asserting only `forced == true` would pass against a constant `true`. The
/// same repository, the same subscriber and the same principal produce one of
/// each here, so neither constant survives.
///
/// The force-push is *allowed* on purpose. No protection rule is installed.
/// This is the audit trail, not the gate: gunnar has to be able to say a branch
/// was rewritten even when rewriting it was permitted, which is exactly the case
/// nobody is watching.
#[test]
fn a_force_push_is_published_as_forced_and_a_fast_forward_is_not() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let harness = Harness::start_with_grpc("team", "project", HashKind::Sha1);
let url = harness.url("team", "project");
let work = harness.dir.path().join("work");
let mut sub = Subscriber::watch(&harness, WatchRequest::default());
// ── one: create ─────────────────────────────────────────────────────────
let first = local_repo(&harness, &work, HashKind::Sha1, "one\n");
harness
.git(&work, &["push", &url, "HEAD:refs/heads/main"])
.expect_ok("create refs/heads/main");
let created = sub.collect_until(&harness, "the RefUpdated for the create", |seen| {
ref_updated(seen).is_some()
});
let created = ref_updated(&created).expect("checked above");
assert_eq!(created.new_oid, first);
assert!(
!created.forced,
"a create has no previous tip to fail to descend from, so it is not a force-push"
);
// ── two: a fast-forward ─────────────────────────────────────────────────
std::fs::write(work.join("hello.txt"), "two\n").unwrap();
harness.git(&work, &["add", "hello.txt"]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "-m", "two"])
.expect_ok("commit");
let second = rev(&harness, &work, "HEAD");
harness
.git(&work, &["push", &url, "HEAD:refs/heads/main"])
.expect_ok("fast-forward refs/heads/main");
let ff = sub.collect_until(&harness, "the RefUpdated for the fast-forward", |seen| {
seen.iter().any(|e| match &e.payload {
Some(event::Payload::RefUpdated(u)) => u.new_oid == second,
_ => false,
})
});
let ff = ff
.iter()
.find_map(|e| match &e.payload {
Some(event::Payload::RefUpdated(u)) if u.new_oid == second => Some(u),
_ => None,
})
.expect("checked above");
assert_eq!(ff.old_oid, first, "the fast-forward's old tip");
assert!(
!ff.forced,
"{second} descends from {first}, so this is a fast-forward and must not be reported as \
a force-push"
);
// ── three: a force-push ─────────────────────────────────────────────────
//
// `--amend` rewrites the second commit, so the new tip has the FIRST commit
// as its parent and does not descend from the tip the reference holds.
std::fs::write(work.join("hello.txt"), "rewritten\n").unwrap();
harness.git(&work, &["add", "hello.txt"]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "--amend", "-m", "rewritten"])
.expect_ok("amend");
let rewritten = rev(&harness, &work, "HEAD");
assert_ne!(
rewritten, second,
"the amend must have produced a new commit"
);
assert_eq!(
rev(&harness, &work, "HEAD~1"),
first,
"the rewritten commit must sit on the first one, or it is not a divergence"
);
harness
.git(&work, &["push", "--force", &url, "HEAD:refs/heads/main"])
.expect_ok("force-push refs/heads/main");
let forced = sub.collect_until(&harness, "the RefUpdated for the force-push", |seen| {
seen.iter().any(|e| match &e.payload {
Some(event::Payload::RefUpdated(u)) => u.new_oid == rewritten,
_ => false,
})
});
let forced = forced
.iter()
.find_map(|e| match &e.payload {
Some(event::Payload::RefUpdated(u)) if u.new_oid == rewritten => Some(u),
_ => None,
})
.expect("checked above");
assert_eq!(
(forced.old_oid.as_str(), forced.new_oid.as_str()),
(second.as_str(), rewritten.as_str()),
"the event must describe the rewrite: {second} replaced by {rewritten}"
);
assert!(
forced.forced,
"{rewritten} does not descend from {second}, so this push REWROTE refs/heads/main and \
the event must say so. `forced == false` here means every force-push on the server is \
published as an ordinary one"
);
}
// ───────────────────────────────────────────────────────────────────────────
// Which key pushed this
// ───────────────────────────────────────────────────────────────────────────
/// **A principal with TWO keys pushes with the second one, and the event names
/// the second one**.
///
/// # Why two keys, and why this is the only fixture that could ever fail
///
/// Until `ServiceRequest` carried the key, `gunnar-cli` recovered the
/// fingerprint by reading the identity table back, and answered only when the
/// principal held **exactly one key**, because with two there is nothing in the
/// table that says which one made the connection. So every account with a
/// rotation key, and every human with a laptop key and a desktop key, published
/// an **empty** fingerprint on every `RefUpdated`, `RefDeleted` and attestation.
/// The audit trail was blank for precisely the accounts it exists for.
///
/// A one-key fixture cannot see that: the guess is right there, and it is right
/// for the wrong reason. This test registers a second key, pushes with it, and
/// asserts the published fingerprint is the **second** key's, which fails
/// against the old empty-string behaviour *and* against any implementation that
/// answers with "the principal's first key".
///
/// The expected value comes from `ssh-keygen -lf` on the key `ssh` was told to
/// offer, not from gunnar recomputing it: gunnar agreeing with gunnar proves
/// nothing.
#[test]
fn the_event_names_the_key_that_authenticated_when_the_principal_holds_two() {
assert!(
tools_present(),
"this test requires `git`, `ssh` and `ssh-keygen` on PATH"
);
let harness = Harness::start_with_grpc("team", "twokeys", HashKind::Sha1);
// A second key for the SAME principal, minted the way the harness mints
// the first one.
let private = gunnar_core::mint_ed25519().expect("mint a second client key");
let parsed =
gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("the minted key parses");
let public = parsed
.public_key()
.to_openssh()
.expect("render the public key");
let second_key_path = harness.dir.path().join("id_ed25519_second");
std::fs::write(&second_key_path, &private).expect("write the second key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&second_key_path, std::fs::Permissions::from_mode(0o600))
.expect("0600, or ssh refuses the key");
}
harness
.stores
.auth()
.register_key("tester", &public, None)
.expect("register the second key");
// The premise. If the principal ends up holding one key, the fixture is
// degenerate and the old guess would have been right, so say so rather than
// report a pass.
let held = harness
.stores
.auth()
.keys_of("tester")
.expect("read the identity table");
assert_eq!(
held.len(),
2,
"this test is about a principal with TWO keys; it holds {}",
held.len()
);
let expected = {
let out = std::process::Command::new("ssh-keygen")
.args(["-lf", second_key_path.to_str().unwrap()])
.output()
.expect("ssh-keygen is on PATH");
assert!(out.status.success(), "ssh-keygen -lf failed");
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.find(|w| w.starts_with("SHA256:"))
.expect("a SHA256 fingerprint")
.to_owned()
};
assert_ne!(
expected, harness.key_fingerprint,
"the two keys fingerprint the same, so nothing below can tell them apart"
);
let mut sub = Subscriber::watch(&harness, WatchRequest::default());
let work = harness.dir.path().join("work");
local_repo(&harness, &work, HashKind::Sha1, "which key pushed this\n");
// Push with the SECOND key, explicitly, and with `IdentitiesOnly` so `ssh`
// cannot quietly fall back to the first one.
let ssh = format!(
"ssh -i {} -o IdentitiesOnly=yes -o IdentityAgent=none -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR",
second_key_path.display()
);
harness
.git_with_ssh(
&work,
&ssh,
&[
"push",
&format!(
"ssh://gunnar@127.0.0.1:{}/team/twokeys.git",
harness.ssh_port
),
"HEAD:refs/heads/main",
],
)
.expect_ok("push with the second key");
let events = sub.collect_until(&harness, "a RefUpdated and its attestation", |seen| {
ref_updated(seen).is_some() && attestation(seen).is_some()
});
let updated = ref_updated(&events)
.unwrap_or_else(|| panic!("no RefUpdated on the stream: {}", summarise(&events)));
assert_eq!(
updated.key_fingerprint, expected,
"the event names {:?}, but the push was made with the key OpenSSH fingerprints as {}. \
An empty string here is the old behaviour: the fingerprint was recovered from \
the identity table and given up on whenever the principal held more than one key",
updated.key_fingerprint, expected
);
assert_eq!(updated.principal_id, "tester");
let att = attestation(&events)
.unwrap_or_else(|| panic!("no PushAttestation on the stream: {}", summarise(&events)));
assert_eq!(
att.key_fingerprint, expected,
"the attestation names a different key than the connection used"
);
}