git-ents.gitmain
⌘K
foforge
receive.rs768 lines · 25.9 KB · rusthistorycomment on this file
1//! Integration tests for `receive`: the mandatory/advisory gate-policy
2//! table (rstest — the spec's two named policies), redaction enforcement
3//! (admin-only push, ingest refusal), `(effect, oid)` dedup, and the
4//! reconstructibility proof that a boot-time [`reconcile`] rebuilds exactly
5//! the obligations incremental `receive` calls would have enqueued.
6
7#![expect(
8 clippy::expect_used,
9 clippy::indexing_slicing,
10 clippy::panic,
11 reason = "integration test: fixtures panic on setup failure"
12)]
13
14use ents_gate::Config;
15use ents_model::{Effect, MemberId, Provenance, Redaction, ResultRecord, Status, namespace};
16use ents_receive::{
17 Identity, MemoryEventSink, Mode, NullEventSink, Proposal, RefTransition, TxResult,
18 propose_genesis, receive, reconcile,
19};
20use ents_testutil::{
21 CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity,
22};
23use gix::refs::FullName;
24use gix_hash::ObjectId;
25use gix_object::{Kind, Write as _};
26use gix_ref_store::RefStoreRead as _;
27use rstest::rstest;
28
29const ADMIN_SEED: u8 = 1;
30const GUEST_SEED: u8 = 2;
31
32/// A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on
33/// `ents-forge`, which itself depends on this crate): any multi-field
34/// entity exercises the same gate-policy and redaction-ingest machinery,
35/// which is generic over the typed tree.
36#[derive(Debug, Clone, PartialEq, Eq, facet::Facet)]
37struct Issue {
38 title: String,
39 body: String,
40 state: String,
41}
42
43/// A forge fixture with verification in force: an admin-registered member
44/// `admin`, a self-attested member `guest`, and an epoch recorded in
45/// `refs/meta/config` — the same shape `ents-gate`'s own tests use.
46struct Forge {
47 refs: MemRefStore,
48 objects: ObjectStore,
49 admin: Keypair,
50 guest: Keypair,
51}
52
53fn forge() -> Forge {
54 let refs = MemRefStore::default();
55 let objects = ObjectStore::default();
56 let admin = Keypair::from_seed(ADMIN_SEED);
57 let guest = Keypair::from_seed(GUEST_SEED);
58 enroll_member(
59 &refs,
60 &objects,
61 "admin",
62 &admin,
63 Provenance::AdminRegistered,
64 100,
65 );
66 enroll_member(
67 &refs,
68 &objects,
69 "guest",
70 &guest,
71 Provenance::SelfAttested,
72 110,
73 );
74 let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid");
75 write_meta_entity(
76 &refs,
77 &objects,
78 config_ref,
79 &Config { epoch: Some(200) },
80 Some(&admin),
81 200,
82 );
83 Forge {
84 refs,
85 objects,
86 admin,
87 guest,
88 }
89}
90
91fn name(s: &str) -> FullName {
92 s.try_into().expect("valid refname in test")
93}
94
95/// Build a signed (or unsigned) parentless genesis commit for `entity`,
96/// *without* moving any ref — unlike `ents_testutil::write_meta_entity`, so
97/// the test can hand the result to `receive` and observe whether *it* moves
98/// the ref. The commit names no ref (the gate recomputes the binding from
99/// signed content); callers derive the refname from the returned oid where
100/// the namespace is hash-identified.
101fn build_mutation<T: for<'facet> facet::Facet<'facet>>(
102 objects: &ObjectStore,
103 entity: &T,
104 signer: Option<&Keypair>,
105 seconds: i64,
106) -> ObjectId {
107 let tree = facet_git_tree::serialize_into(entity, objects).expect("serializes");
108 write_commit(
109 objects,
110 &CommitSpec {
111 tree,
112 parents: vec![],
113 message: "Mutate entity".into(),
114 seconds,
115 },
116 signer,
117 )
118}
119
120/// The oid-keyed refname of a hash-identified issue genesis
121/// (`meta-ref.identity-binding`).
122fn issue_ref(genesis: ObjectId) -> FullName {
123 name(&format!("refs/meta/issues/{genesis}"))
124}
125
126fn sample_issue() -> Issue {
127 Issue {
128 title: "t".into(),
129 body: "b".into(),
130 state: "open".into(),
131 }
132}
133
134fn single(transition: RefTransition, objects: Vec<ObjectId>) -> Proposal {
135 Proposal {
136 transitions: vec![transition],
137 objects,
138 auth: None,
139 }
140}
141
142// ---------------------------------------------------------------------
143// receive.unit, receive.shared-path, receive.refstore-seam: the gate
144// policy table — mandatory aborts the whole batch on a failing verdict,
145// advisory writes regardless and only annotates.
146// ---------------------------------------------------------------------
147
148#[rstest]
149#[case::mandatory_authorized(Mode::Mandatory, true, TxResult::Applied, true)]
150#[case::mandatory_unauthorized(Mode::Mandatory, false, TxResult::Refused, false)]
151#[case::advisory_authorized(Mode::Advisory, true, TxResult::Applied, true)]
152#[case::advisory_unauthorized(Mode::Advisory, false, TxResult::Applied, false)]
153// @relation(receive.unit, receive.shared-path, receive.refstore-seam, receive.object-access, receive.proposal-shape, gate.mandatory-hosted, gate.advisory-local, scope=function, role=Verifies)
154fn gate_policy_matches_mode(
155 #[case] mode: Mode,
156 #[case] authorized: bool,
157 #[case] expected: TxResult,
158 #[case] expect_verdict_pass: bool,
159) {
160 let forge = forge();
161 let signer = if authorized {
162 &forge.admin
163 } else {
164 &forge.guest
165 };
166 let tip = build_mutation(&forge.objects, &sample_issue(), Some(signer), 300);
167 let refname = issue_ref(tip);
168
169 let outcome = receive(
170 &forge.refs,
171 &forge.objects,
172 &NullEventSink,
173 &single(
174 RefTransition {
175 name: refname.clone(),
176 old: None,
177 new: Some(tip),
178 },
179 vec![tip],
180 ),
181 mode,
182 )
183 .expect("evaluates");
184
185 assert_eq!(outcome.result, expected);
186 assert_eq!(outcome.verdicts.len(), 1);
187 let (_, verdict) = outcome.verdicts.first().expect("exactly one transition");
188 assert_eq!(verdict.is_pass(), expect_verdict_pass);
189
190 let landed = forge
191 .refs
192 .get(refname.as_ref())
193 .expect("readable")
194 .is_some();
195 assert_eq!(landed, matches!(expected, TxResult::Applied));
196}
197
198// ---------------------------------------------------------------------
199// receive.redaction-admin-only: a push to refs/meta/redactions/* is
200// refused unless the pusher is admin-registered — a consequence of
201// composing receive with ents-gate's existing authorization arm, pinned
202// here at the receive level.
203// ---------------------------------------------------------------------
204
205#[rstest]
206#[case::admin_registered(true, TxResult::Applied)]
207#[case::self_attested(false, TxResult::Refused)]
208// @relation(receive.redaction-admin-only, scope=function, role=Verifies)
209fn redaction_ref_push_requires_admin(#[case] as_admin: bool, #[case] expected: TxResult) {
210 let forge = forge();
211 let refname = namespace::redaction_ref("r1").expect("valid");
212 let signer = if as_admin { &forge.admin } else { &forge.guest };
213 let redaction = Redaction::new(ObjectId::null(gix_hash::Kind::Sha1), "leaked credential");
214 let tip = build_mutation(&forge.objects, &redaction, Some(signer), 300);
215
216 let outcome = receive(
217 &forge.refs,
218 &forge.objects,
219 &NullEventSink,
220 &single(
221 RefTransition {
222 name: refname,
223 old: None,
224 new: Some(tip),
225 },
226 vec![tip],
227 ),
228 Mode::Mandatory,
229 )
230 .expect("evaluates");
231
232 assert_eq!(outcome.result, expected);
233}
234
235// ---------------------------------------------------------------------
236// receive.redaction-ingest: a redacted hole cannot be silently refilled
237// by re-pushing the same bytes.
238// ---------------------------------------------------------------------
239
240#[rstest]
241// @relation(receive.redaction-ingest, scope=function, role=Verifies)
242fn reintroducing_a_redacted_object_refuses_the_whole_batch() {
243 let forge = forge();
244
245 // A blob that was, at some point, the payload of a leaked credential.
246 let leaked = forge
247 .objects
248 .write_buf(Kind::Blob, b"super secret")
249 .expect("write");
250 let redaction_ref = namespace::redaction_ref("r1").expect("valid");
251 write_meta_entity(
252 &forge.refs,
253 &forge.objects,
254 redaction_ref,
255 &Redaction::new(leaked, "leaked credential"),
256 Some(&forge.admin),
257 250,
258 );
259
260 // Someone tries to push it back in, as part of an ordinary issue
261 // mutation's object graph.
262 let tip = build_mutation(&forge.objects, &sample_issue(), Some(&forge.admin), 300);
263 let issue_ref = issue_ref(tip);
264
265 let outcome = receive(
266 &forge.refs,
267 &forge.objects,
268 &NullEventSink,
269 &single(
270 RefTransition {
271 name: issue_ref.clone(),
272 old: None,
273 new: Some(tip),
274 },
275 vec![tip, leaked],
276 ),
277 Mode::Mandatory,
278 )
279 .expect("evaluates");
280
281 assert_eq!(outcome.result, TxResult::Redacted { oid: leaked });
282 assert!(
283 outcome.verdicts.is_empty(),
284 "refused before any verdict was evaluated"
285 );
286 assert!(
287 forge
288 .refs
289 .get(issue_ref.as_ref())
290 .expect("readable")
291 .is_none(),
292 "the whole batch must be refused, not just the redacted object"
293 );
294}
295
296// ---------------------------------------------------------------------
297// receive.dedup: redelivering the same (effect, oid) pair is a no-op.
298// ---------------------------------------------------------------------
299
300#[rstest]
301// @relation(receive.dedup, scope=function, role=Verifies)
302fn memory_sink_deduplicates_by_effect_and_oid() {
303 use ents_receive::EventSink as _;
304
305 let sink = MemoryEventSink::default();
306 let oid = ObjectId::null(gix_hash::Kind::Sha1);
307
308 sink.enqueue("unit", oid).expect("infallible");
309 sink.enqueue("unit", oid).expect("infallible");
310 sink.enqueue("integration", oid).expect("infallible");
311
312 assert_eq!(
313 sink.pending(),
314 vec![("integration".to_owned(), oid), ("unit".to_owned(), oid),]
315 );
316}
317
318// ---------------------------------------------------------------------
319// receive.reconstructible: the boot-time scan rebuilds exactly the
320// obligations incremental `receive` calls would have enqueued.
321// ---------------------------------------------------------------------
322
323/// Two chained empty-tree commits, built deterministically (fixed actor,
324/// fixed tree, fixed seconds) so two independent object stores produce
325/// byte-identical oids — letting path A and path B below compare `pending()`
326/// sets directly, oids included, without sharing any state.
327fn chain_commits(objects: &ObjectStore, count: usize, start_seconds: i64) -> Vec<ObjectId> {
328 let tree = ents_testutil::empty_tree(objects);
329 let mut parent = None;
330 let mut out = Vec::with_capacity(count);
331 for i in 0..count {
332 let seconds = start_seconds.saturating_add(i64::try_from(i).unwrap_or(i64::MAX));
333 let commit = write_commit(
334 objects,
335 &CommitSpec {
336 tree,
337 parents: parent.into_iter().collect(),
338 message: format!("commit {i} at {seconds}"),
339 seconds,
340 },
341 None,
342 );
343 parent = Some(commit);
344 out.push(commit);
345 }
346 out
347}
348
349#[rstest]
350// @relation(receive.reconstructible, receive.event-sink, receive.never-blocks, query.workset, scope=function, role=Verifies)
351fn reconcile_matches_incremental_delivery() {
352 let effect = Effect {
353 name: "unit".to_owned(),
354 trigger: "rev(refs/heads/main)".to_owned(),
355 toolchains: vec![],
356 run: "true".to_owned(),
357 };
358 let effect_ref: FullName = "refs/meta/effects/unit".try_into().expect("valid");
359 let main = name("refs/heads/main");
360
361 // Path A: two `receive` calls, each advancing refs/heads/main by one
362 // commit — the only thing that ever moves this ref — incrementally
363 // enqueuing into a live sink.
364 let incremental = {
365 let refs = MemRefStore::default();
366 let objects = ObjectStore::default();
367 write_meta_entity(&refs, &objects, effect_ref.clone(), &effect, None, 50);
368 let sink = MemoryEventSink::default();
369
370 let mut old = None;
371 for commit in chain_commits(&objects, 2, 100) {
372 let outcome = receive(
373 &refs,
374 &objects,
375 &sink,
376 &single(
377 RefTransition {
378 name: main.clone(),
379 old,
380 new: Some(commit),
381 },
382 vec![],
383 ),
384 Mode::Advisory,
385 )
386 .expect("evaluates");
387 assert_eq!(outcome.result, TxResult::Applied);
388 old = Some(commit);
389 }
390 sink.pending()
391 };
392
393 // Path B: an independent store, seeded directly to the same final
394 // state (as if `refs/heads/main` had already advanced through two
395 // accepted pushes whose enqueues were lost — a crashed in-memory
396 // sink, say) — then reconstructed from repository state alone, with
397 // no incremental delivery at all.
398 let reconciled = {
399 let refs = MemRefStore::default();
400 let objects = ObjectStore::default();
401 write_meta_entity(&refs, &objects, effect_ref, &effect, None, 50);
402 let commits = chain_commits(&objects, 2, 100);
403 refs.set(main.as_ref(), *commits.last().expect("non-empty chain"));
404
405 let sink = MemoryEventSink::default();
406 reconcile(&refs, &objects, &sink).expect("reconciles");
407 sink.pending()
408 };
409
410 assert_eq!(incremental, reconciled);
411 assert_eq!(
412 incremental.len(),
413 2,
414 "one obligation per commit that entered the trigger's set"
415 );
416}
417
418// ---------------------------------------------------------------------
419// model.review-pin: the retention pin's commit shape — empty tree,
420// parents include the retained commit, merge-shaped fast-forward on
421// every advance — admitted by the identical mandatory gate every entity
422// mutation faces.
423// ---------------------------------------------------------------------
424
425/// Read a commit's `(tree, parents)` back out of the store.
426fn commit_shape(objects: &ObjectStore, oid: ObjectId) -> (ObjectId, Vec<ObjectId>) {
427 use gix_object::Find as _;
428
429 let mut buf = Vec::new();
430 let data = objects
431 .try_find(&oid, &mut buf)
432 .expect("readable")
433 .expect("present");
434 assert_eq!(data.kind, Kind::Commit);
435 let commit = gix_object::CommitRef::from_bytes(data.data, oid.kind()).expect("parses");
436 (commit.tree(), commit.parents().collect())
437}
438
439/// A first pin retains the reviewed commit as its only parent and carries
440/// the empty tree; a re-review advances the pin fast-forward with a
441/// merge-shaped commit `(previous pin tip, newly reviewed commit)` — and
442/// the *mandatory* gate admits both shapes (`gate.tip-signed`,
443/// `gate.fast-forward`: descent through any parent).
444// @relation(model.review-pin, meta-ref.namespace, gate.fast-forward, scope=function, role=Verifies)
445#[test]
446fn pin_retains_every_reviewed_round_and_passes_the_mandatory_gate() {
447 let forge = forge();
448 let identity = ents_receive::Identity {
449 actor: gix::actor::Signature {
450 name: "admin".into(),
451 email: "admin@ents.test".into(),
452 time: gix::date::Time {
453 seconds: 300,
454 offset: 0,
455 },
456 },
457 author: None,
458 sign: &|payload| forge.admin.sign(payload),
459 };
460 let rounds = chain_commits(&forge.objects, 2, 250);
461 let (first_round, second_round) = (rounds[0], rounds[1]);
462 let pin = namespace::review_pin_ref("deadbeef", &MemberId::new("admin")).expect("valid");
463 let empty = ents_testutil::empty_tree(&forge.objects);
464
465 // First review: the pin's tip has the reviewed commit as its only
466 // parent and carries no entity — the empty tree.
467 let outcome = ents_receive::propose_pin(
468 &forge.refs,
469 &forge.objects,
470 &NullEventSink,
471 pin.clone(),
472 first_round,
473 &identity,
474 "Pin review 7",
475 Mode::Mandatory,
476 )
477 .expect("reaches an outcome");
478 assert_eq!(outcome.result, TxResult::Applied);
479 assert!(outcome.verdicts[0].1.is_pass(), "mandatory gate admits it");
480 let first_tip = forge
481 .refs
482 .get(pin.as_ref())
483 .expect("readable")
484 .expect("set");
485 let (tree, parents) = commit_shape(&forge.objects, first_tip);
486 assert_eq!(tree, empty, "a pin commit carries the empty tree");
487 assert_eq!(parents, vec![first_round]);
488
489 // Re-review after the target moved: merge-shaped fast-forward
490 // (previous pin tip, newly reviewed commit) — every reviewed round
491 // stays retained in the pin's own history.
492 let outcome = ents_receive::propose_pin(
493 &forge.refs,
494 &forge.objects,
495 &NullEventSink,
496 pin.clone(),
497 second_round,
498 &identity,
499 "Pin review 7 again",
500 Mode::Mandatory,
501 )
502 .expect("reaches an outcome");
503 assert_eq!(outcome.result, TxResult::Applied);
504 assert!(outcome.verdicts[0].1.is_pass());
505 let second_tip = forge
506 .refs
507 .get(pin.as_ref())
508 .expect("readable")
509 .expect("set");
510 let (tree, parents) = commit_shape(&forge.objects, second_tip);
511 assert_eq!(tree, empty);
512 assert_eq!(
513 parents,
514 vec![first_tip, second_round],
515 "previous pin tip first, newly reviewed commit second"
516 );
517}
518
519/// A self-attested member is not authorized for the pin namespace — the
520/// gate's canonical-ref arm applies to `refs/meta/pins/*` unchanged.
521// @relation(model.review-pin, gate.tip-signed, scope=function, role=Verifies)
522#[test]
523fn pin_writes_face_the_same_authorization_as_any_canonical_ref() {
524 let forge = forge();
525 let identity = ents_receive::Identity {
526 actor: gix::actor::Signature {
527 name: "guest".into(),
528 email: "guest@ents.test".into(),
529 time: gix::date::Time {
530 seconds: 300,
531 offset: 0,
532 },
533 },
534 author: None,
535 sign: &|payload| forge.guest.sign(payload),
536 };
537 let reviewed = chain_commits(&forge.objects, 1, 250)[0];
538 let pin = namespace::review_pin_ref("deadbeef", &MemberId::new("admin")).expect("valid");
539
540 let outcome = ents_receive::propose_pin(
541 &forge.refs,
542 &forge.objects,
543 &NullEventSink,
544 pin,
545 reviewed,
546 &identity,
547 "Pin review 7",
548 Mode::Mandatory,
549 )
550 .expect("reaches an outcome");
551 assert_eq!(outcome.result, TxResult::Refused);
552}
553
554// ---------------------------------------------------------------------
555// Identity binding driven through receive: the replays the binding closes.
556// ---------------------------------------------------------------------
557
558fn comment_ref(genesis: ObjectId) -> FullName {
559 name(&format!("refs/meta/comments/{genesis}"))
560}
561
562/// The doppelgänger replay: a signed *mutation* commit (one with a parent)
563/// re-proposed as the genesis of a fresh entity is refused, because the
564/// all-roots walk reaches the original genesis, not the replayed commit
565/// (`gate.identity-binding`, `meta-ref.identity-binding`). This, not a
566/// creation-time-only check, is what makes the replay impossible.
567// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
568#[test]
569fn a_signed_mutation_replayed_as_a_fresh_genesis_is_refused() {
570 let forge = forge();
571 // A legitimate comment genesis and one signed mutation of it.
572 let genesis = build_mutation(&forge.objects, &sample_issue(), Some(&forge.admin), 300);
573 forge.refs.set(comment_ref(genesis).as_ref(), genesis);
574 let mut edited = sample_issue();
575 edited.state = "resolved".into();
576 let tree = facet_git_tree::serialize_into(&edited, &forge.objects).expect("ser");
577 let mutation = write_commit(
578 &forge.objects,
579 &CommitSpec {
580 tree,
581 parents: vec![genesis],
582 message: "Resolve".into(),
583 seconds: 310,
584 },
585 Some(&forge.admin),
586 );
587
588 // Re-propose that mutation commit as the genesis of a brand-new
589 // comment named for its own oid.
590 let outcome = receive(
591 &forge.refs,
592 &forge.objects,
593 &NullEventSink,
594 &single(
595 RefTransition {
596 name: comment_ref(mutation),
597 old: None,
598 new: Some(mutation),
599 },
600 vec![mutation],
601 ),
602 Mode::Mandatory,
603 )
604 .expect("evaluates");
605
606 assert_eq!(outcome.result, TxResult::Refused);
607 let (_, verdict) = &outcome.verdicts[0];
608 let ents_gate::Verdict::Fail(refusal) = verdict else {
609 panic!("the replay must be refused: {verdict:?}");
610 };
611 assert_eq!(refusal.requirement, ents_gate::Requirement::IdentityBinding);
612 assert!(
613 forge
614 .refs
615 .get(comment_ref(mutation).as_ref())
616 .expect("readable")
617 .is_none(),
618 "no doppelgänger entity was created"
619 );
620}
621
622/// The result replay: a signed `pass` re-proposed for a different effect,
623/// or a different commit, is refused — the result tree carries its own
624/// effect and target, so the refname is a function of signed content
625/// (`model.result-identity`, `gate.identity-binding`).
626// @relation(model.result-identity, gate.identity-binding, scope=function, role=Verifies)
627#[rstest]
628#[case::wrong_effect("lint", "abc123")]
629#[case::wrong_commit("unit", "ffffff")]
630fn a_signed_pass_replayed_for_another_effect_or_commit_is_refused(
631 #[case] effect_seg: &str,
632 #[case] short: &str,
633) {
634 let forge = forge();
635 let target =
636 ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("valid hex");
637 let record = ResultRecord::new("unit", target, Status::Pass);
638 let tip = build_mutation(&forge.objects, &record, Some(&forge.admin), 300);
639
640 // The honest ref is results/unit/abc123; the replay targets a
641 // different effect or commit segment.
642 let replay = namespace::result_ref(effect_seg, short).expect("valid");
643 let outcome = receive(
644 &forge.refs,
645 &forge.objects,
646 &NullEventSink,
647 &single(
648 RefTransition {
649 name: replay.clone(),
650 old: None,
651 new: Some(tip),
652 },
653 vec![tip],
654 ),
655 Mode::Mandatory,
656 )
657 .expect("evaluates");
658
659 assert_eq!(outcome.result, TxResult::Refused);
660 assert!(forge.refs.get(replay.as_ref()).expect("readable").is_none());
661}
662
663/// Creation via the inbox still works: a self-attested member creates a
664/// hash-identified entity under its own inbox segment, awaiting adoption
665/// (`gate.owner-mutation`: creation stays provenance-keyed). The
666/// sign-then-name genesis flow names the ref from the commit's own oid.
667// @relation(gate.owner-mutation, meta-ref.inbox, meta-ref.identity-binding, scope=function, role=Verifies)
668#[test]
669fn a_self_attested_member_creates_a_comment_in_its_inbox() {
670 let forge = forge();
671 let identity = Identity {
672 actor: gix::actor::Signature {
673 name: "guest".into(),
674 email: "guest@ents.test".into(),
675 time: gix::date::Time {
676 seconds: 300,
677 offset: 0,
678 },
679 },
680 author: None,
681 sign: &|payload| forge.guest.sign(payload),
682 };
683
684 let (landed, outcome) = propose_genesis(
685 &forge.refs,
686 &forge.objects,
687 &NullEventSink,
688 &sample_issue(),
689 |oid| namespace::inbox_ref(&MemberId::new("guest"), &format!("comments/{oid}")),
690 &identity,
691 "Comment awaiting adoption",
692 Mode::Mandatory,
693 )
694 .expect("reaches an outcome");
695
696 assert_eq!(outcome.result, TxResult::Applied, "{:?}", outcome.verdicts);
697 assert!(
698 landed
699 .as_bstr()
700 .starts_with(b"refs/meta/inbox/guest/comments/"),
701 "created under the contributor's own inbox segment: {landed}"
702 );
703 assert!(forge.refs.get(landed.as_ref()).expect("readable").is_some());
704}
705
706/// An attributed mutation ("member via the web") carries the attributed
707/// member in the commit's author slot while the committer — and the
708/// signature the gate judges — stays the signing identity; the mandatory
709/// gate admits it exactly as it would the unattributed form
710/// (`receive.attributed-author`).
711// @relation(receive.attributed-author, scope=function, role=Verifies)
712#[test]
713fn an_attributed_author_lands_in_the_author_slot_and_the_gate_judges_the_signer() {
714 use gix_object::Find as _;
715
716 let forge = forge();
717 let identity = Identity {
718 actor: gix::actor::Signature {
719 name: "admin".into(),
720 email: "admin@ents.test".into(),
721 time: gix::date::Time {
722 seconds: 300,
723 offset: 0,
724 },
725 },
726 author: Some(gix::actor::Signature {
727 name: "guest".into(),
728 email: "guest@ents.test".into(),
729 time: gix::date::Time {
730 seconds: 290,
731 offset: 0,
732 },
733 }),
734 sign: &|payload| forge.admin.sign(payload),
735 };
736 let refname = namespace::redaction_ref("r-attr").expect("valid");
737 let redaction = Redaction::new(ObjectId::null(gix_hash::Kind::Sha1), "leaked credential");
738
739 let outcome = ents_receive::propose_entity(
740 &forge.refs,
741 &forge.objects,
742 &NullEventSink,
743 refname.clone(),
744 &redaction,
745 &identity,
746 "Redact, attributed",
747 Mode::Mandatory,
748 )
749 .expect("reaches an outcome");
750 assert_eq!(outcome.result, TxResult::Applied, "{:?}", outcome.verdicts);
751
752 let tip = forge
753 .refs
754 .get(refname.as_ref())
755 .expect("readable")
756 .expect("written");
757 let mut buf = Vec::new();
758 let data = forge
759 .objects
760 .try_find(&tip, &mut buf)
761 .expect("readable")
762 .expect("present");
763 let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("parses");
764 let author = commit.author().expect("author parses");
765 let committer = commit.committer().expect("committer parses");
766 assert_eq!(author.name, "guest", "attributed author");
767 assert_eq!(committer.name, "admin", "signing committer");
768}