crates/gunnar-cli/tests/forge_service.rs
#![cfg(all(feature = "server", feature = "control"))]
//! **The forge over gRPC** (roadmap phase 1).
//!
//! `forge_spine.rs` proves the evidence: an approval is signed by the
//! reviewer's own key, names the commit it approved, and does not survive a
//! force-push. This proves the **service**: the same evidence, reached through
//! `Forge.*` against the real [`CoreControlPlane`], with the properties the
//! wire adds a way to break —
//!
//! * the server verifies the signature **before** it appends, so an event that
//! does not check out never enters the log at all;
//! * a caller cannot file an event in another principal's name, even with a
//! key the server knows; and
//! * the **record** decides whether a review may be added at all. That last one
//! is what the split between the two halves buys. `Forge` owns what people
//! said; `PullRequests` owns whether there is anything to say it about. A
//! review event on a number nobody opened is evidence about nothing, and one
//! on a pull request that has already merged cannot change what happened — so
//! this service asks [`gunnar_grpc::ControlPlane::get_pull`] rather than
//! keeping a second opinion of its own. There is no `Opened` event here to
//! ask instead: that fact has one authority now.
//!
//! Every arm is red-first — the refusal is driven before the accepted case,
//! because an `Append` that took anything at all would pass the green half on
//! its own — and every refusal also asserts that **nothing was written**. A
//! service that returned an error and appended anyway looks identical from the
//! caller's side, and it is the log that ships.
mod common;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use common::Harness;
use gunnar_cli::server::CoreControlPlane;
use gunnar_core::forge::{sign_event, Event, ForgeEvent};
use gunnar_core::HashKind;
use gunnar_grpc::{
AuthMode, Caller, ControlPlane, EventBus, ForgeService, MergeOptions, ServerConfig,
ServiceContext,
};
use gunnar_proto::forge_server::Forge as _;
use gunnar_proto::{
AppendEventRequest, GetIssueRequest, GetReviewRequest, ListIssuesRequest, ListReviewsRequest,
PullRequestState, RepoPath, SignedForgeEvent,
};
use tonic::Request;
const NAMESPACE: &str = "team";
const NAME: &str = "reviewed";
const REPO: &str = "team/reviewed";
/// Drive one RPC to completion.
///
/// The services are `async` because tonic's trait is, but every `Forge` handler
/// is synchronous inside — it validates, calls the seam, and returns. The
/// harness already owns a runtime and starting a second one inside it is an
/// error, so the tests are plain `#[test]` and this is where the future is
/// polled.
fn now<F: std::future::Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a current-thread runtime")
.block_on(future)
}
/// `x.pipe(f)` is `f(x)`, so an RPC call reads in the order it happens instead
/// of being wrapped from the outside in.
trait Pipe: Sized {
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
f(self)
}
}
impl<T> Pipe for T {}
/// One request, with a verified caller already attached.
///
/// Authentication is left **on** in [`Fixture::open`] and the caller is
/// attached by hand, which is what the gate does in the server. A test that
/// turned authentication off could say nothing at all about the actor check,
/// because that check is about *who the caller is*.
fn as_caller<T>(message: T, principal: &str) -> Request<T> {
let mut request = Request::new(message);
request.extensions_mut().insert(Caller {
principal_id: principal.to_owned(),
fingerprint: String::new(),
machine: false,
authenticated: true,
});
request
}
/// A [`gunnar_core::SignedEvent`] on the wire, byte for byte.
///
/// Deliberately a re-encoding and not a conversion the server also uses: what
/// these tests hand to `Append` has to be what a client would send, and a
/// helper shared with the plane could hide a field that never survives the
/// round trip.
fn wire(signed: &gunnar_core::SignedEvent) -> SignedForgeEvent {
use gunnar_proto::forge_event::Kind;
let kind = match &signed.event.event {
Event::Commented {
body,
path,
line,
blob_oid,
} => Kind::Commented(gunnar_proto::Commented {
body: body.clone(),
path: path.clone(),
line: *line,
blob_oid: blob_oid.clone(),
}),
Event::Approved { commit } => Kind::Approved(gunnar_proto::Approved {
commit: commit.clone(),
}),
Event::Rejected { commit, reason } => Kind::Rejected(gunnar_proto::Rejected {
commit: commit.clone(),
reason: reason.clone(),
}),
Event::IssueOpened { title, body } => Kind::IssueOpened(gunnar_proto::IssueOpened {
title: title.clone(),
body: body.clone(),
}),
Event::IssueCommented { body } => {
Kind::IssueCommented(gunnar_proto::IssueCommented { body: body.clone() })
}
Event::IssueClosed { reason } => Kind::IssueClosed(gunnar_proto::IssueClosed {
reason: reason.clone(),
}),
};
SignedForgeEvent {
event: Some(gunnar_proto::ForgeEvent {
repo: signed.event.repo.clone(),
pull: signed.event.pull,
actor: signed.event.actor.clone(),
at_unix_ms: signed.event.at_unix_ms,
kind: Some(kind),
}),
key_fingerprint: signed.key_fingerprint.clone(),
signature: signed.signature.clone(),
attested: signed.attested.map(|a| gunnar_proto::ForgeAttestation {
session_stepped_up_at_unix_ms: a.session_stepped_up_at_unix_ms,
}),
}
}
fn event(pull: u64, actor: &str, at: i64, event: Event) -> ForgeEvent {
ForgeEvent {
repo: REPO.into(),
pull,
actor: actor.into(),
at_unix_ms: at,
event,
}
}
/// **A server with a real pull request on it.**
///
/// Real in both halves, because the two halves are the thing under test: the
/// branches exist in the repository because stock `git` pushed them, and the
/// pull request exists because [`ControlPlane::create_pull`] resolved those
/// branches and wrote a record under the control store's CAS. A fixture that
/// invented a number would be testing a `Forge` whose authority is itself,
/// which is the design this merge replaced.
struct Fixture {
/// The real plane. The record half is driven through it directly —
/// `create_pull`, `close_pull` — so an arm about *the record deciding* is
/// not also an arm about `PullRequests`' own service layer.
plane: Arc<CoreControlPlane>,
/// The service under test, over the same plane and the same stores.
forge: ForgeService,
/// The open pull request every arm files events against.
pull: u64,
/// Where `refs/heads/topic` pointed when that pull request was created.
topic: String,
/// The working tree, so an arm can push the branch on afterwards.
work: PathBuf,
}
impl Fixture {
/// Push `main` and `topic`, then open a pull request from one to the other.
fn open(harness: &Harness) -> Fixture {
let work = harness.dir.path().join("work");
let url = harness.url(NAMESPACE, NAME);
harness
.git(
harness.dir.path(),
&[
"init",
"--quiet",
"-b",
"main",
work.to_str().expect("a utf-8 temp path"),
],
)
.expect_ok("git init");
std::fs::write(work.join("a.txt"), "base\n").expect("write");
harness.git(&work, &["add", "."]).expect_ok("git add");
harness
.git(&work, &["commit", "--quiet", "-m", "base"])
.expect_ok("git commit");
harness
.git(&work, &["push", "--quiet", &url, "main"])
.expect_ok("push main");
harness
.git(&work, &["checkout", "--quiet", "-b", "topic"])
.expect_ok("git checkout -b topic");
let topic = commit_and_push(harness, &work, "proposed\n", "the proposal");
let plane = Arc::new(CoreControlPlane::new(Arc::clone(&harness.stores)));
let control: Arc<dyn ControlPlane> = plane.clone();
let forge = ForgeService::new(ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig {
auth: AuthMode::Required,
..ServerConfig::default()
},
));
let pull = plane
.create_pull(
&path(),
"tester",
"the proposal",
"",
"refs/heads/topic",
"refs/heads/main",
)
.expect("open a pull request")
.number;
assert_ne!(
pull, 0,
"a pull request number of 0 would make every `pull` field below indistinguishable \
from an unset one"
);
Fixture {
plane,
forge,
pull,
topic,
work,
}
}
/// `Forge.Append`, as `principal`.
fn append(
&self,
signed: &gunnar_core::SignedEvent,
principal: &str,
) -> Result<u64, tonic::Status> {
self.forge
.append(as_caller(
AppendEventRequest {
event: Some(wire(signed)),
},
principal,
))
.pipe(now)
.map(|response| response.into_inner().index)
}
}
fn path() -> RepoPath {
RepoPath::new(NAMESPACE, NAME)
}
/// Add a commit on the current branch and push it, returning the new tip.
fn commit_and_push(harness: &Harness, work: &Path, line: &str, message: &str) -> String {
let existing = std::fs::read_to_string(work.join("a.txt")).unwrap_or_default();
std::fs::write(work.join("a.txt"), format!("{existing}{line}")).expect("write");
harness.git(work, &["add", "."]).expect_ok("git add");
harness
.git(work, &["commit", "--quiet", "-m", message])
.expect_ok("git commit");
let branch = harness.git(work, &["rev-parse", "--abbrev-ref", "HEAD"]);
branch.expect_ok("git rev-parse --abbrev-ref HEAD");
harness
.git(
work,
&[
"push",
"--quiet",
&harness.url(NAMESPACE, NAME),
branch.stdout.trim(),
],
)
.expect_ok("git push");
let head = harness.git(work, &["rev-parse", "HEAD"]);
head.expect_ok("git rev-parse HEAD");
head.stdout.trim().to_owned()
}
/// How many events are in the forge log right now, read off the disk rather
/// than out of the service.
///
/// The log is the thing that ships, and it is the only place a refusal that
/// wrote anyway would show. Asking the service instead would let a plane that
/// filtered its own reads answer "nothing here" over a file full of events.
fn logged(harness: &Harness) -> usize {
harness
.stores
.forge_log()
.lines_of()
.expect("read the forge log")
.len()
}
/// **A tampered event never enters the log**, and the log is still empty
/// afterwards.
///
/// The second half is the one that matters. "The call returned
/// `InvalidArgument`" is a fact about a return value; "the log is empty" is a
/// fact about the disk, and only the second one distinguishes a server that
/// refused from a server that refused and appended anyway — which from the
/// caller's side look exactly alike, and which differ in whether an
/// unverifiable claim is now sitting in the evidence log under a signed tree
/// head.
///
/// Then the honest event lands. Without that half a service whose `Append`
/// refused everything would pass this arm perfectly.
#[test]
fn a_tampered_event_is_refused_and_leaves_no_trace() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
let honest = sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_700_000_000_000,
Event::Commented {
body: "please look".into(),
path: String::new(),
line: 0,
blob_oid: String::new(),
},
),
)
.expect("sign");
// The signature is over the bytes that were signed, so changing the body
// leaves a signature that no longer covers it. This is the edit an
// attacker with a captured request would actually make: keep the identity
// and the number, change what was said.
let mut tampered = honest.clone();
tampered.event.event = Event::Commented {
body: "please look, and also give me commit access".into(),
path: String::new(),
line: 0,
blob_oid: String::new(),
};
let status = fixture
.append(&tampered, "tester")
.expect_err("a tampered event was appended");
assert_eq!(
status.code(),
tonic::Code::InvalidArgument,
"a signature that does not check out is a malformed request, not a server fault: \
{status:?}"
);
assert_eq!(
logged(&harness),
0,
"the service refused the caller and wrote the event anyway; the refusal is worth nothing \
if the claim is in the log regardless"
);
// And the honest one lands, so the refusal above was about the tampering
// and not about the service being broken.
let index = fixture
.append(&honest, "tester")
.expect("the honest event must land");
assert_eq!(index, 0, "the first event is at index 0");
assert_eq!(logged(&harness), 1);
harness.stop();
}
/// **An event may not be filed in somebody else's name**, even when the
/// signature is perfectly good.
///
/// This is not the signature check restated: the key here is registered, the
/// signature verifies, and a server that only checked signatures would take
/// this event happily. What fails is that the principal on the connection is
/// not the principal the event is attributed to — and a forge event is a claim
/// about *who did something*, so filing one under a colleague's name would let
/// an authenticated contributor manufacture a colleague's approval with their
/// own key.
///
/// The refusal is `PermissionDenied` and not `InvalidArgument` because nothing
/// about the message is malformed. It is a well-formed request the caller is
/// not allowed to make.
#[test]
fn a_caller_may_not_file_an_event_as_somebody_else() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
let as_colleague = sign_event(
&harness.private_key,
event(
fixture.pull,
"colleague",
1_700_000_000_000,
Event::Approved {
commit: fixture.topic.clone(),
},
),
)
.expect("sign");
let status = fixture
.append(&as_colleague, "tester")
.expect_err("an event was filed in another principal's name");
assert_eq!(status.code(), tonic::Code::PermissionDenied, "{status:?}");
assert_eq!(
logged(&harness),
0,
"an approval attributed to somebody who never made it is in the log"
);
// The same key, the same commit, the same everything except the name on
// it — and it lands. So the refusal was about the attribution.
let as_self = sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_700_000_001_000,
Event::Approved {
commit: fixture.topic.clone(),
},
),
)
.expect("sign");
fixture
.append(&as_self, "tester")
.expect("an event in the caller's own name must land");
assert_eq!(logged(&harness), 1);
harness.stop();
}
/// **The record decides whether a review may be added at all.**
///
/// This arm is the point of the split. There is no `Opened` event any more, so
/// "does this pull request exist" is not a question the forge log can answer
/// about itself — it asks [`ControlPlane::get_pull`], and the record's state is
/// the authority for the answer. Two consequences, both driven here:
///
/// * an event on a number nobody opened is `NotFound`. It would otherwise be
/// evidence about nothing — a signed approval of a commit, attached to a pull
/// request that does not exist, which a later reader could verify and still
/// not know what it meant.
/// * an event on a **closed** pull request is `FailedPrecondition`. A review
/// cannot change what has already been decided, and an approval appended
/// after the fact is exactly the shape of a backdated one.
///
/// Both refusals come first, and both assert the log is untouched; then the
/// same event on the open pull request lands, so the gate is not simply a
/// service that refuses every append.
#[test]
fn a_review_needs_a_pull_request_that_exists_and_is_open() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
// A number nobody opened. `fixture.pull` is the only one there is, so this
// is a number the record store has never issued.
let nowhere = fixture.pull + 99;
let orphan = sign_event(
&harness.private_key,
event(
nowhere,
"tester",
1_700_000_000_000,
Event::Commented {
body: "about what?".into(),
path: String::new(),
line: 0,
blob_oid: String::new(),
},
),
)
.expect("sign");
let status = fixture
.append(&orphan, "tester")
.expect_err("an event landed on a pull request that does not exist");
assert_eq!(
status.code(),
tonic::Code::NotFound,
"a review of a pull request nobody opened is evidence about nothing: {status:?}"
);
assert_eq!(logged(&harness), 0, "an orphan event is in the log");
// A pull request that exists and has been closed. Closed and not merged on
// purpose: merging needs an approval, and this arm must not depend on the
// gate it is a sibling of.
let closed = fixture
.plane
.close_pull(&path(), fixture.pull)
.expect("close the pull request");
assert_eq!(
closed.state,
PullRequestState::Closed as i32,
"the record must actually be closed, or the refusal below proves nothing"
);
let late = sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_700_000_001_000,
Event::Approved {
commit: fixture.topic.clone(),
},
),
)
.expect("sign");
let status = fixture
.append(&late, "tester")
.expect_err("an approval landed on a closed pull request");
assert_eq!(
status.code(),
tonic::Code::FailedPrecondition,
"a review event cannot change what has already been decided: {status:?}"
);
assert_eq!(
logged(&harness),
0,
"an approval was appended to a pull request that was already closed"
);
// A second pull request over the same branches, open — and the same event
// lands on it. So the two refusals above were about the record's state and
// not about `Append` refusing everything.
let open = fixture
.plane
.create_pull(
&path(),
"tester",
"the proposal, again",
"",
"refs/heads/topic",
"refs/heads/main",
)
.expect("open a second pull request")
.number;
assert_ne!(
open, fixture.pull,
"numbers are never reused, so a fresh record must have a fresh number"
);
let welcome = sign_event(
&harness.private_key,
event(
open,
"tester",
1_700_000_002_000,
Event::Approved {
commit: fixture.topic.clone(),
},
),
)
.expect("sign");
fixture
.append(&welcome, "tester")
.expect("an event on an open pull request must land");
assert_eq!(logged(&harness), 1);
harness.stop();
}
/// **A review reads back folded, with every approval re-checked right now.**
///
/// Three separate claims, in the order a reader meets them.
///
/// First, the empty one, and it is asserted **before** anything is appended:
/// `GetReview` on a pull request nobody has reviewed is an empty
/// [`gunnar_core::forge::Review`], not a `NotFound`. That is deliberate and is
/// documented on
/// [`gunnar_core::forge::fold`] — "nobody has said anything yet" and "there is
/// no such pull request" are different facts with different owners, and the
/// second one belongs to `PullRequests`. It is also the red half here: a
/// service that fabricated state would already be caught by the time the first
/// event is written.
///
/// Second, the fold. What comes back is what the events say, computed on every
/// read, so two readers of the same log always agree.
///
/// Third, and the reason this is not a display: each approval carries
/// `verified` and a `verdict` from re-checking the signature against the identity key
/// table **at read time**. A count would say "1 approval" whether or not the
/// key behind it still exists.
#[test]
fn a_review_reads_back_folded_with_each_approval_re_verified() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
// Red first: nothing has been said, and the answer is an empty review of a
// pull request that certainly exists — not an error, and not an invented
// approval.
let empty = fixture
.forge
.get_review(as_caller(
GetReviewRequest {
repo: REPO.into(),
pull: fixture.pull,
},
"tester",
))
.pipe(now)
.expect("a pull request nobody has reviewed is not an error")
.into_inner();
let state = empty.state.expect("a state");
assert_eq!(state.pull, fixture.pull);
assert_eq!(state.repo, REPO);
assert!(
state.approvals.is_empty(),
"an approval nobody signed came back from an empty log"
);
assert_eq!(state.comments, 0);
assert!(empty.events.is_empty());
// And nothing is listed either, because a review with no events is not a
// review. `ListReviews` answers about reviews; `PullRequests.List` answers
// about pull requests.
assert!(
fixture
.forge
.list_reviews(as_caller(
ListReviewsRequest { repo: REPO.into() },
"tester"
))
.pipe(now)
.expect("list")
.into_inner()
.reviews
.is_empty(),
"a pull request nobody has reviewed must not appear as a review"
);
for signed in [
sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_700_000_000_000,
Event::Commented {
body: "looks right".into(),
path: String::new(),
line: 0,
blob_oid: String::new(),
},
),
)
.expect("sign"),
sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_700_000_001_000,
Event::Approved {
commit: fixture.topic.clone(),
},
),
)
.expect("sign"),
] {
fixture.append(&signed, "tester").expect("append");
}
let answer = fixture
.forge
.get_review(as_caller(
GetReviewRequest {
repo: REPO.into(),
pull: fixture.pull,
},
"tester",
))
.pipe(now)
.expect("get the review")
.into_inner();
let state = answer.state.expect("a state");
assert_eq!(state.pull, fixture.pull);
assert_eq!(state.comments, 1);
assert_eq!(
answer.events.len(),
2,
"every event on this pull request comes back, exactly as it was signed"
);
assert_eq!(state.approvals.len(), 1);
let approval = &state.approvals[0];
assert_eq!(approval.actor, "tester");
assert_eq!(approval.commit, fixture.topic);
assert!(
approval.verified,
"an approval by a key that is in the identity table must verify: {}",
approval.verdict
);
assert_eq!(
approval.verdict, "verified",
"the verdict is the words a human reads next to `verified`, and it has to agree with it"
);
// Listing sees it now, and reports the same fold.
let listed = fixture
.forge
.list_reviews(as_caller(
ListReviewsRequest { repo: REPO.into() },
"tester",
))
.pipe(now)
.expect("list")
.into_inner()
.reviews;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].pull, fixture.pull);
assert_eq!(listed[0].approvals.len(), 1);
assert_eq!(listed[0].approvals[0].commit, fixture.topic);
harness.stop();
}
/// **An approval names the commit that was reviewed, and a later commit is not
/// approved by it.**
///
/// The branch advances *after* the review, which is the ordinary case and not
/// an attack: somebody pushes one more fix. What must not happen is that the
/// approval quietly comes to cover the new tip, because then the commit that
/// ships is one nobody read. The commit is inside the signature, so it cannot;
/// this arm asserts that the service reports it that way, which is what the
/// merge gate in `merge_pull` then acts on — it looks for a verified approval
/// naming the tip **now**, not the tip the record was created at.
///
/// Red first: the approval of the old commit is written and the new tip is
/// shown to be unapproved. Then the reviewer approves the new tip and it is
/// approved, so "unapproved" is a state this fixture can actually leave.
#[test]
fn an_approval_still_names_the_commit_that_was_reviewed() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
let reviewed = fixture.topic.clone();
fixture
.append(
&sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_700_000_000_000,
Event::Approved {
commit: reviewed.clone(),
},
),
)
.expect("sign"),
"tester",
)
.expect("append the approval");
// One more commit on `topic`, pushed. The record still names the old tip
// in `source_oid` and the branch does not: that gap is the whole reason
// the merge gate reads the branch and not the record.
let after = commit_and_push(&harness, &fixture.work, "one more fix\n", "one more fix");
assert_ne!(after, reviewed, "the branch did not actually move");
assert_eq!(
fixture
.plane
.get_pull(&path(), fixture.pull)
.expect("read the record")
.source_oid,
reviewed,
"the record's oid is the tip at creation and must not follow the branch either"
);
let approvals = |fixture: &Fixture| {
fixture
.forge
.get_review(as_caller(
GetReviewRequest {
repo: REPO.into(),
pull: fixture.pull,
},
"tester",
))
.pipe(now)
.expect("get the review")
.into_inner()
.state
.expect("a state")
.approvals
};
let state = approvals(&fixture);
assert_eq!(state.len(), 1);
assert_eq!(
state[0].commit, reviewed,
"the approval must name what was reviewed"
);
assert_ne!(
state[0].commit, after,
"the approval followed the branch, so a commit nobody read is now approved and the merge \
gate would fast-forward onto it"
);
// The reviewer looks at the new tip and approves it. The fold is keyed by
// actor, so this replaces the earlier approval rather than adding a second
// one — and the commit it names is now the one that is there.
fixture
.append(
&sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_700_000_001_000,
Event::Approved {
commit: after.clone(),
},
),
)
.expect("sign"),
"tester",
)
.expect("append the second approval");
let state = approvals(&fixture);
assert_eq!(
state.len(),
1,
"one actor holds one approval; a second row would let a reviewer approve two tips at once"
);
assert_eq!(state[0].commit, after);
assert!(state[0].verified, "{}", state[0].verdict);
harness.stop();
}
/// **An event signed with somebody else's registered key is refused at the
/// seam, even when the caller and the actor agree.**
///
/// The service layer's check above is about the CONNECTION: the caller may not
/// attribute an event to a colleague. This one is about the KEY: `tester` on
/// the wire, `tester` in the event — and the signature is mallory's, whose key
/// is registered and whose signature is perfectly good. A seam that only asked
/// "is this key in the table" took this event until 2026-08-31, and an
/// approval that carries anybody-registered's signature is not the actor's own
/// word. The refusal names whose key it actually is, because "your signature
/// failed" sends the operator to the wrong problem.
#[test]
fn an_event_signed_with_anothers_key_is_refused_at_the_seam() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
// mallory: a real principal, a real registered key.
let mallory = gunnar_core::mint_ed25519().expect("mint a key");
let mallory =
gunnar_core::ssh_key::PrivateKey::from_openssh(&mallory).expect("the minted key parses");
harness
.stores
.auth()
.add_principal(
gunnar_core::Principal::new("mallory", "mallory", gunnar_core::PrincipalKind::User)
.expect("valid"),
)
.expect("add the principal");
harness
.stores
.auth()
.register_key(
"mallory",
&mallory
.public_key()
.to_openssh()
.expect("render the public key"),
None,
)
.expect("register the key");
let borrowed = sign_event(
&mallory,
event(
fixture.pull,
"tester",
1_700_000_000_000,
Event::Approved {
commit: fixture.topic.clone(),
},
),
)
.expect("sign");
let status = fixture
.append(&borrowed, "tester")
.expect_err("an event signed with another principal's key was appended");
assert_eq!(
status.code(),
tonic::Code::InvalidArgument,
"the request is well-formed about a key that cannot speak for this actor: {status:?}"
);
assert!(
status.message().contains("mallory"),
"the refusal must name whose key actually signed: {status:?}"
);
assert_eq!(
logged(&harness),
0,
"the seam refused and wrote the event anyway"
);
// The actor's own key lands, so the refusal was about the binding.
let own = sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_700_000_001_000,
Event::Approved {
commit: fixture.topic.clone(),
},
),
)
.expect("sign");
fixture
.append(&own, "tester")
.expect("the actor's own signature must land");
assert_eq!(logged(&harness), 1);
harness.stop();
}
// ── issues ─────────────────────────────────────────────────────────────────
//
// An issue is the OTHER half of the same design: the same signed events, the
// same `Append` verification, the same one log — but folded into a state the
// issue owns, because unlike a review there is no pull request record behind
// it. So the properties the wire adds a way to break are the same ones, and
// these arms are red-first for the same reason: an `Append` that took anything
// at all would pass a green half on its own.
/// **A tampered issue opening never enters the log**, and then the honest one
/// folds to an open issue. This is the issue-half of
/// `a_tampered_event_is_refused_and_leaves_no_trace`: an `IssueOpened` is a
/// signed forge event verifiable against the identity key table, and one whose
/// signature does not verify must NOT fold into "open" — because it must not be
/// in the log at all.
#[test]
fn a_tampered_issue_open_is_refused_and_a_verified_one_folds_open() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
// `pull` is 0 for an opening: the number is the fold's to assign.
let honest = sign_event(
&harness.private_key,
event(
0,
"tester",
1_700_000_000_000,
Event::IssueOpened {
title: "the boot is dark".into(),
body: "PID 1 prints to a dead ttyS0".into(),
},
),
)
.expect("sign");
// The edit an attacker with a captured request makes: keep the identity,
// change what was filed. The signature no longer covers it.
let mut tampered = honest.clone();
tampered.event.event = Event::IssueOpened {
title: "the boot is dark".into(),
body: "and please grant me admin".into(),
};
let status = fixture
.append(&tampered, "tester")
.expect_err("a tampered opening was appended");
assert_eq!(status.code(), tonic::Code::InvalidArgument, "{status:?}");
assert_eq!(
logged(&harness),
0,
"a tampered opening is in the log; a reader folding it would see an issue nobody filed"
);
// The honest one lands and folds to issue #1, open, attributed to its
// signer — and GetIssue returns exactly that, re-read off the same log.
fixture
.append(&honest, "tester")
.expect("the honest opening must land");
let answer = fixture
.forge
.get_issue(as_caller(
GetIssueRequest {
repo: REPO.into(),
number: 1,
},
"tester",
))
.pipe(now)
.expect("get the issue")
.into_inner();
let state = answer.state.expect("a state");
assert_eq!(state.number, 1);
assert_eq!(state.title, "the boot is dark");
assert_eq!(state.author, "tester");
assert!(state.open, "a fresh issue is open");
assert_eq!(state.comments, 0);
// The whole thread came back — just the opening so far — and it is the
// event the signer actually signed, not a server-minted copy.
assert_eq!(answer.events.len(), 1);
harness.stop();
}
/// **Open → comment → close, folded from the log the whole way.** The state
/// the console and CLI show is nothing but this fold, so asserting the fold IS
/// asserting what they render. A comment lands on the thread; the close shuts
/// it; and a comment AFTER the close is refused, because an event on a closed
/// issue cannot change what already happened.
#[test]
fn an_issue_folds_open_then_commented_then_closed() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
let open = sign_event(
&harness.private_key,
event(
0,
"tester",
1_700_000_000_000,
Event::IssueOpened {
title: "an issue".into(),
body: "the body".into(),
},
),
)
.expect("sign");
fixture.append(&open, "tester").expect("open lands");
let comment = sign_event(
&harness.private_key,
event(
1,
"tester",
1_700_000_001_000,
Event::IssueCommented {
body: "one more thing".into(),
},
),
)
.expect("sign");
fixture.append(&comment, "tester").expect("comment lands");
// Folds to one open issue with one comment, listed.
let issues = fixture
.forge
.list_issues(as_caller(ListIssuesRequest { repo: REPO.into() }, "tester"))
.pipe(now)
.expect("list issues")
.into_inner()
.issues;
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].comments, 1);
assert!(issues[0].open);
// Close it.
let close = sign_event(
&harness.private_key,
event(
1,
"tester",
1_700_000_002_000,
Event::IssueClosed {
reason: "fixed".into(),
},
),
)
.expect("sign");
fixture.append(&close, "tester").expect("close lands");
let after = fixture
.forge
.get_issue(as_caller(
GetIssueRequest {
repo: REPO.into(),
number: 1,
},
"tester",
))
.pipe(now)
.expect("get the issue")
.into_inner()
.state
.expect("a state");
assert!(!after.open, "an IssueClosed shuts it");
assert_eq!(after.comments, 1);
// A comment after the close is evidence about nothing, and the record —
// here the fold itself — refuses it. Nothing is written.
let before = logged(&harness);
let late = sign_event(
&harness.private_key,
event(
1,
"tester",
1_700_000_003_000,
Event::IssueCommented {
body: "reopening by comment?".into(),
},
),
)
.expect("sign");
let status = fixture
.append(&late, "tester")
.expect_err("a comment on a closed issue was appended");
assert_eq!(status.code(), tonic::Code::FailedPrecondition, "{status:?}");
assert_eq!(
logged(&harness),
before,
"a comment on a closed issue is in the log"
);
harness.stop();
}
// ── GHUNT-19: a person's event from a stepped-up passkey session ──────────
//
// A person in a browser holds an identity-core passkey session and no key of
// theirs, so their approval cannot be their own sshsig. Gunnar takes such an
// event UNSIGNED from the session only when it stepped up (a fresh,
// user-verified touch of the person's own passkey), and signs it itself with
// its own forge-attestation key: the actor named, the step-up inside the
// signature. Without the touch the call is refused by name (`[stepup]`), so the
// console can answer it with the touch in place.
/// One request from `principal`'s identity-core session, the way the gate
/// leaves it: the caller and the session beside it.
fn as_session<T>(message: T, principal: &str, stepped_up: bool, at: Option<i64>) -> Request<T> {
let mut request = as_caller(message, principal);
request
.extensions_mut()
.insert(gunnar_grpc::auth::PersonSession {
stepped_up,
stepped_up_at_unix_ms: at,
});
request
}
/// An event as a session sends it: unsigned.
fn unsigned(event: ForgeEvent) -> SignedForgeEvent {
let mut wired = wire(&gunnar_core::SignedEvent {
event,
key_fingerprint: String::new(),
signature: String::new(),
attested: None,
});
wired.key_fingerprint.clear();
wired.signature.clear();
wired
}
impl Fixture {
/// `Forge.Append` of a wire event, from `principal`'s session.
fn append_from_session(
&self,
wired: SignedForgeEvent,
principal: &str,
stepped_up: bool,
at: Option<i64>,
) -> Result<u64, tonic::Status> {
self.forge
.append(as_session(
AppendEventRequest { event: Some(wired) },
principal,
stepped_up,
at,
))
.pipe(now)
.map(|response| response.into_inner().index)
}
}
/// ★ **An approval from a stepped-up session is attested by gunnar**: it
/// lands, it names the actor, it carries "attested for session step-up at
/// <time>" inside gunnar's signature, it verifies at review — and the merge
/// gate counts it, so the person's browser approval merges.
#[test]
fn a_stepped_up_sessions_approval_is_attested_by_gunnar_and_names_the_actor() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
let at = 1_761_650_000_000_i64;
fixture
.append_from_session(
unsigned(event(
fixture.pull,
"tester",
1_761_650_001_000,
Event::Approved {
commit: fixture.topic.clone(),
},
)),
"tester",
true,
Some(at),
)
.expect("an approval from a stepped-up session must be attested and land");
assert_eq!(logged(&harness), 1);
let answer = fixture
.forge
.get_review(as_caller(
GetReviewRequest {
repo: REPO.into(),
pull: fixture.pull,
},
"tester",
))
.pipe(now)
.expect("get the review")
.into_inner();
let words = format!(
"attested for session step-up at {}",
gunnar_core::flow::iso8601_utc(at)
);
let state = answer.state.expect("a state");
assert_eq!(state.approvals.len(), 1);
let approval = &state.approvals[0];
assert_eq!(approval.actor, "tester", "the approval names the person");
assert_eq!(approval.commit, fixture.topic);
assert!(
approval.verified,
"gunnar's attestation must verify: {}",
approval.verdict
);
assert_eq!(approval.verdict, words, "the verdict says whose word it is");
let signed = answer.events.last().expect("the event comes back");
let inner = signed.event.as_ref().expect("an event");
assert_eq!(inner.actor, "tester");
assert_eq!(
signed
.attested
.as_ref()
.map(|a| a.session_stepped_up_at_unix_ms),
Some(at),
"the event carries the attestation"
);
assert!(
!signed.key_fingerprint.is_empty() && signed.key_fingerprint != harness.key_fingerprint,
"gunnar signs with its own key, never the person's: {}",
signed.key_fingerprint
);
// The merge gate counts it: the person's browser approval merges.
fixture
.plane
.merge_pull(&path(), fixture.pull, "tester", &MergeOptions::default())
.expect("an attested approval of the tip must merge");
harness.stop();
}
/// ★ **Without a fresh step-up the call is refused by name**, and nothing is
/// written; an unsigned event with no session at all, an attestation asked for
/// another person, and an attestation a client made up are refused too. Then the
/// stepped-up one lands, so the refusals were about the touch.
#[test]
fn an_unsigned_event_without_a_fresh_step_up_is_refused_by_name() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
let approval = |actor: &str| {
event(
fixture.pull,
actor,
1_761_650_001_000,
Event::Approved {
commit: fixture.topic.clone(),
},
)
};
// A session that did not step up.
let status = fixture
.append_from_session(unsigned(approval("tester")), "tester", false, None)
.expect_err("an approval from a session that did not step up was taken");
assert_eq!(status.code(), tonic::Code::PermissionDenied, "{status:?}");
assert!(
status.message().starts_with("[stepup]"),
"refused by name: {status:?}"
);
assert_eq!(logged(&harness), 0, "refused and written anyway");
// No session at all: an unsigned event is nobody's word.
let status = fixture
.forge
.append(as_caller(
AppendEventRequest {
event: Some(unsigned(approval("tester"))),
},
"tester",
))
.pipe(now)
.expect_err("an unsigned event with no session was taken");
assert_eq!(status.code(), tonic::Code::InvalidArgument, "{status:?}");
assert_eq!(logged(&harness), 0);
// A stepped-up session asking gunnar to attest somebody else.
let status = fixture
.append_from_session(unsigned(approval("mallory")), "tester", true, Some(1))
.expect_err("an attestation for another person was made");
assert_eq!(status.code(), tonic::Code::PermissionDenied, "{status:?}");
assert_eq!(logged(&harness), 0);
// A client that sends its own "attestation" over its own signature.
let mut forged = wire(&sign_event(&harness.private_key, approval("tester")).expect("sign"));
forged.attested = Some(gunnar_proto::ForgeAttestation {
session_stepped_up_at_unix_ms: 1,
});
let status = fixture
.forge
.append(as_caller(
AppendEventRequest {
event: Some(forged),
},
"tester",
))
.pipe(now)
.expect_err("a client-made attestation was taken");
assert_eq!(status.code(), tonic::Code::InvalidArgument, "{status:?}");
assert_eq!(logged(&harness), 0);
// The fresh touch lands.
fixture
.append_from_session(
unsigned(approval("tester")),
"tester",
true,
Some(1_761_650_000_000),
)
.expect("the stepped-up session's approval lands");
assert_eq!(logged(&harness), 1);
harness.stop();
}
/// ★ **The key-signed path is unchanged**, from a session too: an event the
/// actor signed with their own key lands as their own word ("verified"), not as
/// an attestation, whatever the session says.
#[test]
fn a_key_signed_event_is_the_actors_own_word_even_from_a_session() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
let own = sign_event(
&harness.private_key,
event(
fixture.pull,
"tester",
1_761_650_001_000,
Event::Approved {
commit: fixture.topic.clone(),
},
),
)
.expect("sign");
fixture
.append_from_session(wire(&own), "tester", false, None)
.expect("the actor's own signature lands without any step-up");
let answer = fixture
.forge
.get_review(as_caller(
GetReviewRequest {
repo: REPO.into(),
pull: fixture.pull,
},
"tester",
))
.pipe(now)
.expect("get the review")
.into_inner();
let state = answer.state.expect("a state");
assert_eq!(state.approvals[0].verdict, "verified");
let signed = answer.events.last().expect("the event");
assert!(
signed.attested.is_none(),
"a key-signed event carries no attestation"
);
assert_eq!(signed.key_fingerprint, harness.key_fingerprint);
harness.stop();
}
/// ★ **PUB-ISSUES: the anonymous read gate is gunnar's existing one.** The public
/// site on gunnar.rs reads a repository's issues as the console's GRANTLESS guest
/// principal (no grant anywhere), with gunnar's existing `Forge.ListIssues` /
/// `Forge.GetIssue` and no new data kind. They take Read on the repository, and
/// Read on a PUBLIC repository is everybody's (`AuthTable::may`), so:
///
/// * while the repository is PRIVATE the guest is refused both, exactly as it is
/// refused the repository's files (`PermissionDenied`, the same answer a name
/// nobody holds gets, so the refusal is no existence oracle);
/// * once it is PUBLIC the guest reads the list and the whole thread;
/// * filing or commenting still needs a grant: the guest's `Forge.Append` is refused
/// and nothing is written;
/// * made private again, the guest is refused again.
///
/// The public site's own switch (`console.public-site`) is the console's gate, in
/// front of all of this (gunnar-ui gunnar-public-site), so gunnar stays generic.
#[test]
fn a_grantless_principal_reads_a_public_repositorys_issues_and_nothing_private() {
assert!(common::tools_present(), "this test requires git and ssh");
let mut harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let fixture = Fixture::open(&harness);
harness
.stores
.auth()
.add_principal(
gunnar_core::Principal::new("guest", "guest", gunnar_core::PrincipalKind::User)
.expect("valid"),
)
.expect("add the grantless guest");
let open = sign_event(
&harness.private_key,
event(
0,
"tester",
1_761_650_000_000,
Event::IssueOpened {
title: "the boot is dark".into(),
body: "PID 1 prints to a dead ttyS0".into(),
},
),
)
.expect("sign");
fixture.append(&open, "tester").expect("the opening lands");
let comment = sign_event(
&harness.private_key,
event(
1,
"tester",
1_761_650_001_000,
Event::IssueCommented {
body: "on Mondays only".into(),
},
),
)
.expect("sign");
fixture
.append(&comment, "tester")
.expect("the comment lands");
let list = |who: &str| {
fixture
.forge
.list_issues(as_caller(ListIssuesRequest { repo: REPO.into() }, who))
.pipe(now)
};
let get = |who: &str| {
fixture
.forge
.get_issue(as_caller(
GetIssueRequest {
repo: REPO.into(),
number: 1,
},
who,
))
.pipe(now)
};
// Private: refused, both reads, by the same refusal a name nobody holds gets.
for refused in [list("guest").map(|_| ()), get("guest").map(|_| ())] {
let status = refused.expect_err("a grantless principal read a private repository's issues");
assert_eq!(status.code(), tonic::Code::PermissionDenied, "{status:?}");
}
let nobody = fixture
.forge
.list_issues(as_caller(
ListIssuesRequest {
repo: "team/no-such-repository".into(),
},
"guest",
))
.pipe(now)
.expect_err("a name nobody holds");
assert_eq!(nobody.code(), tonic::Code::PermissionDenied, "{nobody:?}");
// Public: the list and the whole thread.
harness.set_visibility(NAMESPACE, NAME, gunnar_core::Visibility::Public);
let issues = list("guest")
.expect("the guest lists a public repository's issues")
.into_inner()
.issues;
assert_eq!(issues.len(), 1);
assert_eq!(
(
issues[0].number,
issues[0].title.as_str(),
issues[0].comments
),
(1, "the boot is dark", 1)
);
let thread = get("guest")
.expect("the guest reads a public issue")
.into_inner();
assert_eq!(thread.events.len(), 2, "the opening and the comment");
// Filing still needs a grant, and nothing is written.
let before = logged(&harness);
let filed = sign_event(
&harness.private_key,
event(
0,
"guest",
1_761_650_002_000,
Event::IssueOpened {
title: "a stranger's issue".into(),
body: String::new(),
},
),
)
.expect("sign");
let status = fixture
.append(&filed, "guest")
.expect_err("a grantless principal filed an issue");
assert_eq!(status.code(), tonic::Code::PermissionDenied, "{status:?}");
assert_eq!(
logged(&harness),
before,
"a refused filing wrote to the forge log"
);
// Private again: refused again.
harness.set_visibility(NAMESPACE, NAME, gunnar_core::Visibility::Private);
let status = list("guest").expect_err("the guest still reads a repository made private");
assert_eq!(status.code(), tonic::Code::PermissionDenied, "{status:?}");
harness.stop();
}