git-ents.gitmain
⌘K
foforge
preflight.rs255 lines · 8.7 KB · rusthistorycomment on this file
1//! Pre-flight, inbox routing, and the local-advisory boundary
2//! (`sync.pre-flight`, `sync.inbox-routing`, `sync.local-advisory`).
3//!
4//! Strategy: **rstest table-driven** for the enumerable cases (pass vs the
5//! two kinds of refusal, and the refname-mapping table for [`inbox_route`]),
6//! plus targeted integration tests for the two invariants that are about
7//! *identity* rather than a case: a pre-flight verdict equals the gate's own
8//! verdict on the same inputs (`sync.pre-flight`), and a failing pre-flight
9//! never blocks a local write (`sync.local-advisory`).
10
11#![expect(clippy::unwrap_used, reason = "tests")]
12
13use ents_gate::{Config, Update, verify};
14use ents_model::{MemberId, Provenance, namespace};
15use ents_sync::{inbox_route, preflight};
16use ents_testutil::{
17 CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity,
18};
19use gix::refs::FullName;
20use gix_hash::ObjectId;
21use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead};
22use rstest::rstest;
23
24/// A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on
25/// `ents-forge`): any multi-field entity exercises the same pre-flight and
26/// inbox-routing machinery, which is generic over the typed tree.
27#[derive(Debug, Clone, PartialEq, Eq, facet::Facet)]
28struct Issue {
29 title: String,
30 body: String,
31 state: String,
32}
33
34fn issue() -> Issue {
35 Issue {
36 title: "t".into(),
37 body: "b".into(),
38 state: "open".into(),
39 }
40}
41
42/// Build a parentless, signed genesis issue commit and the oid-keyed
43/// refname it binds (`meta-ref.identity-binding`). Does not touch the ref
44/// store — pre-flight judges a proposal against a snapshot.
45fn genesis_issue(objects: &ObjectStore, signer: &Keypair, seconds: i64) -> (FullName, ObjectId) {
46 let tree = facet_git_tree::serialize_into(&issue(), objects).unwrap();
47 let tip = write_commit(
48 objects,
49 &CommitSpec {
50 tree,
51 parents: vec![],
52 message: "Open issue".into(),
53 seconds,
54 },
55 Some(signer),
56 );
57 (format!("refs/meta/issues/{tip}").try_into().unwrap(), tip)
58}
59
60/// A child issue commit of `parent`, signed, for divergence scenarios.
61fn child_issue(
62 objects: &ObjectStore,
63 parent: ObjectId,
64 state: &str,
65 signer: &Keypair,
66 seconds: i64,
67) -> ObjectId {
68 let mut edited = issue();
69 edited.state = state.into();
70 let tree = facet_git_tree::serialize_into(&edited, objects).unwrap();
71 write_commit(
72 objects,
73 &CommitSpec {
74 tree,
75 parents: vec![parent],
76 message: "Edit issue".into(),
77 seconds,
78 },
79 Some(signer),
80 )
81}
82
83/// A booted forge (admin enrolled, epoch set) plus a self-attested bob.
84fn forge() -> (MemRefStore, ObjectStore, Keypair, Keypair) {
85 let refs = MemRefStore::default();
86 let objects = ObjectStore::default();
87 let admin = Keypair::from_seed(1);
88 let bob = Keypair::from_seed(2);
89 enroll_member(
90 &refs,
91 &objects,
92 "admin",
93 &admin,
94 Provenance::AdminRegistered,
95 100,
96 );
97 let config: FullName = namespace::CONFIG_REF.try_into().unwrap();
98 write_meta_entity(
99 &refs,
100 &objects,
101 config,
102 &Config { epoch: Some(200) },
103 Some(&admin),
104 200,
105 );
106 enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250);
107 (refs, objects, admin, bob)
108}
109
110/// A pre-flight against a canonical push that the pusher is not authorized
111/// for offers the author's own inbox route the instant the verdict goes
112/// negative (`sync.inbox-routing`), while an authorized push offers none.
113#[rstest]
114#[case::authorized_pass(1, true, false)]
115#[case::unauthorized_offers_inbox(2, false, true)]
116// @relation(sync.pre-flight, sync.inbox-routing, scope=function, role=Verifies)
117fn preflight_offers_inbox_only_on_an_authorization_refusal(
118 #[case] seed: u8,
119 #[case] expect_pass: bool,
120 #[case] expect_inbox: bool,
121) {
122 let (refs, objects, admin, bob) = forge();
123 let signer = if seed == 1 { &admin } else { &bob };
124 let author = if seed == 1 { "admin" } else { "bob" };
125
126 // The issue's id is its genesis commit's own oid; the ref does not yet
127 // exist, so pre-flight judges a fresh creation.
128 let (name, tip) = genesis_issue(&objects, signer, 300);
129 let pf = preflight(
130 &refs,
131 &objects,
132 &Update {
133 name: name.clone(),
134 new: Some(tip),
135 },
136 &MemberId::new(author),
137 )
138 .unwrap();
139
140 assert_eq!(pf.is_pass(), expect_pass, "{:?}", pf.verdict);
141 assert_eq!(pf.inbox.is_some(), expect_inbox);
142 if let Some(inbox) = pf.inbox {
143 let expected = format!("refs/meta/inbox/bob/issues/{tip}");
144 assert_eq!(inbox.as_bstr(), expected.as_str());
145 }
146}
147
148/// A fast-forward refusal is a *divergence* — the answer is a merge, not the
149/// inbox — so pre-flight offers no inbox route for it, matching the gate's
150/// own `inbox_alternative` signal (`sync.inbox-routing`).
151// @relation(sync.inbox-routing, scope=function, role=Verifies)
152#[test]
153fn a_divergence_refusal_offers_no_inbox() {
154 let (refs, objects, admin, _bob) = forge();
155 // A genesis and two divergent children of it, all correctly bound to
156 // the same oid-keyed ref (both descend from the same genesis, so
157 // identity binding holds); the current tip is one child, and the
158 // proposal is the sibling, which cannot descend from it — a genuine
159 // fast-forward refusal, not an identity mismatch.
160 let (name, genesis) = genesis_issue(&objects, &admin, 300);
161 let current = child_issue(&objects, genesis, "open", &admin, 350);
162 let sibling = child_issue(&objects, genesis, "closed", &admin, 350);
163 refs.set(name.as_ref(), current);
164
165 let pf = preflight(
166 &refs,
167 &objects,
168 &Update {
169 name: name.clone(),
170 new: Some(sibling),
171 },
172 &MemberId::new("admin"),
173 )
174 .unwrap();
175 assert!(!pf.is_pass(), "{:?}", pf.verdict);
176 assert!(
177 pf.inbox.is_none(),
178 "a divergence routes to a merge, not the inbox"
179 );
180}
181
182/// Pre-flight runs the *identical* gate function every call site runs, so
183/// its verdict is always exactly the gate's verdict on the same inputs — a
184/// prediction that can only be stale, never wrong about the rules
185/// (`sync.pre-flight`, `gate.call-sites`).
186// @relation(sync.pre-flight, scope=function, role=Verifies)
187#[test]
188fn preflight_verdict_equals_the_gate_verdict() {
189 let (refs, objects, admin, bob) = forge();
190
191 for (author, signer) in [("admin", &admin), ("bob", &bob)] {
192 let (name, tip) = genesis_issue(&objects, signer, 300);
193 let update = Update {
194 name,
195 new: Some(tip),
196 };
197
198 let pf = preflight(&refs, &objects, &update, &MemberId::new(author)).unwrap();
199 let gate = verify(&refs, &objects, &update).unwrap();
200 assert_eq!(
201 pf.verdict, gate,
202 "pre-flight must be the gate, not a copy of it"
203 );
204 }
205}
206
207/// Sync honors the gate's advisory role locally: a failing pre-flight is a
208/// prediction, not a veto — the same commit still writes to the local store
209/// (`sync.local-advisory`). The consequence sync owns is the inbox offer,
210/// which the failing pre-flight already surfaced.
211// @relation(sync.local-advisory, scope=function, role=Verifies)
212#[test]
213fn a_failing_preflight_never_blocks_the_local_write() {
214 let (refs, objects, _admin, bob) = forge();
215 let (name, tip) = genesis_issue(&objects, &bob, 300);
216
217 let pf = preflight(
218 &refs,
219 &objects,
220 &Update {
221 name: name.clone(),
222 new: Some(tip),
223 },
224 &MemberId::new("bob"),
225 )
226 .unwrap();
227 assert!(!pf.is_pass());
228 assert!(
229 pf.inbox.is_some(),
230 "the rejection consequence is an inbox offer"
231 );
232
233 // Nothing sync did prevents the local write from applying.
234 let outcome = refs
235 .transaction(&[RefEdit {
236 name: name.clone(),
237 expected: Expected::MustNotExist,
238 new: Some(tip),
239 }])
240 .unwrap();
241 assert_eq!(refs.get(name.as_ref()).unwrap(), Some(tip));
242 let _ = outcome;
243}
244
245#[rstest]
246#[case::canonical("refs/meta/issues/42", "refs/meta/inbox/jdc/issues/42")]
247#[case::nested("refs/meta/results/unit/abc", "refs/meta/inbox/jdc/results/unit/abc")]
248#[case::already_inbox("refs/meta/inbox/jdc/issues/1", "refs/meta/inbox/jdc/issues/1")]
249#[case::non_meta("refs/heads/main", "refs/heads/main")]
250// @relation(sync.inbox-routing, scope=function, role=Verifies)
251fn inbox_route_maps_canonical_to_the_authors_segment(#[case] input: &str, #[case] expected: &str) {
252 let canonical: FullName = input.try_into().unwrap();
253 let routed = inbox_route(canonical.as_ref(), &MemberId::new("jdc")).unwrap();
254 assert_eq!(routed.as_bstr(), expected);
255}