steid

@jamesgill /

steid/src/application/webhook_tests.rs
Rust24.0 KBCode·Blame·History·Raw
1use super::*;
2use crate::{
3 domain::{
4 CommitSummary, Email, Membership, MembershipId, ObjectId, OrgId, Organization,
5 PasswordHash, PushEvent, PushEventId, RefName, RepoId, Repository, Role, User, UserId,
6 Visibility,
7 },
8 infrastructure::{
9 clock::FixedClock,
10 git::InMemoryGitQuery,
11 repository::{
12 InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryPushEventRepo, InMemoryRepoRepo,
13 InMemoryUserRepo, InMemoryWebhookDeliveryRepo, InMemoryWebhookRepo,
14 },
15 webhook::InMemoryWebhookSender,
16 },
17};
18
19/// No waiting between retries. The schedule is production's business; what these
20/// tests are about is how many attempts happen and what each records.
21const INSTANT: [Duration; 3] = [Duration::ZERO, Duration::ZERO, Duration::ZERO];
22
23/// The clock every test delivery is stamped by. Fixed, so a recorded time is
24/// something a test can assert rather than something it has to allow for.
25fn delivered_at() -> FixedClock {
26 FixedClock::at_epoch_plus(1_700_000_000)
27}
28
29fn oid(prefix: &str) -> ObjectId {
30 ObjectId::new(format!("{prefix:0>40}")).expect("valid id")
31}
32
33fn outcome(status: Option<u16>, body: &str) -> WebhookOutcome {
34 WebhookOutcome {
35 status,
36 body: body.to_owned(),
37 duration: Duration::from_millis(7),
38 }
39}
40
41struct Fixture {
42 orgs: InMemoryOrgRepo,
43 memberships: InMemoryMembershipRepo,
44 repos: InMemoryRepoRepo,
45 users: InMemoryUserRepo,
46 events: InMemoryPushEventRepo,
47 webhooks: InMemoryWebhookRepo,
48 deliveries: InMemoryWebhookDeliveryRepo,
49 git: InMemoryGitQuery,
50 owner: Actor,
51 owner_id: UserId,
52 member: Actor,
53 handle: OrgName,
54 name: RepoName,
55 repo_id: RepoId,
56}
57
58async fn fixture() -> Fixture {
59 let orgs = InMemoryOrgRepo::new();
60 let memberships = InMemoryMembershipRepo::new();
61 let repos = InMemoryRepoRepo::new();
62
63 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
64 orgs.save(&org).await.expect("save org");
65
66 let owner = UserId::generate();
67 let member = UserId::generate();
68
69 // A real user record for the owner, because a redelivery resolves the pusher of
70 // the *event* rather than being handed one — see [`redeliver`].
71 let users = InMemoryUserRepo::new();
72 users
73 .save(&User::new(
74 owner.clone(),
75 Email::new("ada@example.com").expect("valid email"),
76 PasswordHash::from_trusted("$argon2id$test"),
77 org.id.clone(),
78 ))
79 .await
80 .expect("save user");
81
82 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
83 memberships
84 .save(&Membership::new(
85 MembershipId::generate(),
86 org.id.clone(),
87 user.clone(),
88 role,
89 ))
90 .await
91 .expect("save membership");
92 }
93
94 let repo = Repository::new(
95 RepoId::generate(),
96 org.id.clone(),
97 "steid",
98 None,
99 Visibility::Public,
100 SystemTime::UNIX_EPOCH,
101 )
102 .expect("valid repo");
103 repos.save(&repo).await.expect("save repo");
104
105 Fixture {
106 orgs,
107 memberships,
108 repos,
109 users,
110 events: InMemoryPushEventRepo::new(),
111 webhooks: InMemoryWebhookRepo::new(),
112 deliveries: InMemoryWebhookDeliveryRepo::new(),
113 git: InMemoryGitQuery::default(),
114 owner: Actor::User(owner.clone()),
115 owner_id: owner,
116 member: Actor::User(member),
117 handle: org.name,
118 name: repo.name,
119 repo_id: repo.id,
120 }
121}
122
123impl Fixture {
124 async fn create(&self, actor: &Actor, url: &str) -> Result<CreatedWebhook> {
125 create_webhook(
126 actor,
127 &self.handle,
128 &self.name,
129 url,
130 SystemTime::UNIX_EPOCH,
131 PrivateTargets::Refused,
132 &Stores::new(&self.orgs, &self.memberships, &self.repos),
133 &self.webhooks,
134 )
135 .await
136 }
137
138 async fn list(&self, actor: &Actor) -> Result<Vec<WebhookSummary>> {
139 list_webhooks(
140 actor,
141 &self.handle,
142 &self.name,
143 &Stores::new(&self.orgs, &self.memberships, &self.repos),
144 &self.webhooks,
145 &self.deliveries,
146 )
147 .await
148 }
149
150 async fn view(&self, actor: &Actor, id: &WebhookId) -> Result<WebhookDetail> {
151 self.view_page(actor, id, 1).await
152 }
153
154 async fn view_page(&self, actor: &Actor, id: &WebhookId, page: usize) -> Result<WebhookDetail> {
155 view_webhook(
156 actor,
157 &self.handle,
158 &self.name,
159 id,
160 page,
161 &Stores::new(&self.orgs, &self.memberships, &self.repos),
162 &self.webhooks,
163 &self.deliveries,
164 )
165 .await
166 }
167
168 async fn remove(&self, actor: &Actor, id: &WebhookId) -> Result<()> {
169 delete_webhook(
170 actor,
171 &self.handle,
172 &self.name,
173 id,
174 &Stores::new(&self.orgs, &self.memberships, &self.repos),
175 &self.webhooks,
176 )
177 .await
178 }
179
180 fn context(&self) -> DeliveryContext {
181 DeliveryContext {
182 handle: self.handle.clone(),
183 name: self.name.clone(),
184 repo_url: "https://steid.example/acme/repos/steid".to_owned(),
185 pusher: Some("acme".to_owned()),
186 }
187 }
188
189 fn event(&self, name: &str, before: ObjectId, after: ObjectId) -> PushEvent {
190 PushEvent::new(
191 PushEventId::generate(),
192 self.repo_id.clone(),
193 RefName::from_trusted(name),
194 before,
195 after,
196 // The same person `context()` names, so a redelivery — which resolves the
197 // pusher from the event — rebuilds the identical payload.
198 Some(self.owner_id.clone()),
199 SystemTime::UNIX_EPOCH,
200 )
201 .expect("valid event")
202 }
203
204 async fn set_active(&self, actor: &Actor, id: &WebhookId, active: bool) -> Result<Webhook> {
205 set_webhook_active(
206 actor,
207 &self.handle,
208 &self.name,
209 id,
210 active,
211 &Stores::new(&self.orgs, &self.memberships, &self.repos),
212 &self.webhooks,
213 )
214 .await
215 }
216
217 async fn again(
218 &self,
219 actor: &Actor,
220 hook: &WebhookId,
221 delivery: &WebhookDeliveryId,
222 sender: &InMemoryWebhookSender,
223 ) -> Result<WebhookDelivery> {
224 redeliver(
225 actor,
226 &self.handle,
227 &self.name,
228 hook,
229 delivery,
230 "https://steid.example",
231 &Stores::new(&self.orgs, &self.memberships, &self.repos),
232 &self.users,
233 &self.webhooks,
234 &self.deliveries,
235 &self.events,
236 &self.git,
237 sender,
238 &delivered_at(),
239 )
240 .await
241 }
242
243 /// Records an event the way a push would, so a redelivery has one to find.
244 async fn record(&self, event: &PushEvent) {
245 self.events.save(event).await.expect("save event");
246 }
247
248 async fn deliver(&self, events: &[PushEvent], sender: &InMemoryWebhookSender) {
249 deliver_push(
250 events,
251 &self.context(),
252 &self.git,
253 &self.webhooks,
254 &self.deliveries,
255 sender,
256 &delivered_at(),
257 &INSTANT,
258 )
259 .await
260 .expect("delivery should not fail the push");
261 }
262}
263
264// --- registering ---------------------------------------------------------------
265
266#[tokio::test]
267async fn a_hook_is_created_active_and_its_secret_shown_once() {
268 let f = fixture().await;
269
270 let created = f
271 .create(&f.owner, "https://example.com/hook")
272 .await
273 .expect("should create");
274
275 assert!(created.webhook.active);
276 assert!(created.secret.reveal().starts_with("steid_whs_"));
277
278 let listed = f.list(&f.owner).await.expect("should list");
279 assert_eq!(listed.len(), 1);
280 assert_eq!(listed[0].url.as_str(), "https://example.com/hook");
281 assert_eq!(listed[0].last, None, "nothing has been delivered yet");
282}
283
284#[tokio::test]
285async fn a_url_that_would_reach_this_machine_is_refused() {
286 // The SSRF rule, reached through the use case rather than only unit-tested on
287 // the value object: this is the path a form actually takes.
288 let f = fixture().await;
289
290 assert!(
291 f.create(&f.owner, "http://127.0.0.1:3000/hook")
292 .await
293 .is_err()
294 );
295 assert!(f.create(&f.owner, "file:///etc/passwd").await.is_err());
296 assert!(f.list(&f.owner).await.expect("list").is_empty());
297}
298
299#[tokio::test]
300async fn only_an_owner_can_add_read_or_remove_a_hook() {
301 let f = fixture().await;
302 let created = f
303 .create(&f.owner, "https://example.com/hook")
304 .await
305 .expect("should create");
306
307 for actor in [&f.member, &Actor::Anonymous] {
308 assert!(f.create(actor, "https://example.com/other").await.is_err());
309 assert!(f.list(actor).await.is_err());
310 assert!(f.view(actor, &created.webhook.id).await.is_err());
311 assert!(f.remove(actor, &created.webhook.id).await.is_err());
312 }
313
314 assert_eq!(f.list(&f.owner).await.expect("list").len(), 1);
315}
316
317#[tokio::test]
318async fn a_hook_id_from_another_repository_is_not_found_rather_than_forbidden() {
319 // Trying ids must not tell anyone which ones exist elsewhere.
320 let f = fixture().await;
321
322 let error = f
323 .view(&f.owner, &WebhookId::generate())
324 .await
325 .expect_err("should refuse");
326
327 assert!(matches!(
328 error,
329 Error::Domain(DomainError::NotFound { entity: "webhook" })
330 ));
331}
332
333#[tokio::test]
334async fn removing_a_hook_takes_it_off_the_list() {
335 let f = fixture().await;
336 let created = f
337 .create(&f.owner, "https://example.com/hook")
338 .await
339 .expect("should create");
340
341 f.remove(&f.owner, &created.webhook.id)
342 .await
343 .expect("should remove");
344
345 assert!(f.list(&f.owner).await.expect("list").is_empty());
346}
347
348// --- the payload ---------------------------------------------------------------
349
350#[test]
351fn a_payload_names_the_ref_both_ends_and_the_repository() {
352 let context = DeliveryContext {
353 handle: OrgName::new("acme").expect("valid"),
354 name: RepoName::new("steid").expect("valid"),
355 repo_url: "https://steid.example/acme/repos/steid".to_owned(),
356 pusher: Some("acme".to_owned()),
357 };
358 let event = PushEvent::new(
359 PushEventId::generate(),
360 RepoId::generate(),
361 RefName::from_trusted("refs/heads/main"),
362 oid("a"),
363 oid("b"),
364 None,
365 SystemTime::UNIX_EPOCH,
366 )
367 .expect("valid event");
368
369 let body = push_payload(
370 &event,
371 &context,
372 &[CommitSummary {
373 id: oid("b"),
374 summary: "docs: say what changed".to_owned(),
375 author_name: "Ada Lovelace".to_owned(),
376 committed_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000),
377 }],
378 );
379
380 let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
381
382 assert_eq!(parsed["version"], PAYLOAD_VERSION);
383 assert_eq!(parsed["event"], "push");
384 assert_eq!(parsed["ref"], "refs/heads/main");
385 assert_eq!(parsed["before"], oid("a").as_str());
386 assert_eq!(parsed["after"], oid("b").as_str());
387 assert_eq!(parsed["repository"]["handle"], "acme");
388 assert_eq!(parsed["repository"]["name"], "steid");
389 assert_eq!(
390 parsed["repository"]["url"],
391 "https://steid.example/acme/repos/steid"
392 );
393 assert_eq!(parsed["pusher"]["handle"], "acme");
394 assert_eq!(parsed["commits"][0]["id"], oid("b").as_str());
395 assert_eq!(parsed["commits"][0]["summary"], "docs: say what changed");
396 assert_eq!(parsed["commits"][0]["author"], "Ada Lovelace");
397 assert_eq!(parsed["commits"][0]["timestamp"], 1_700_000_000_i64);
398}
399
400// --- delivering ----------------------------------------------------------------
401
402#[tokio::test]
403async fn a_delivery_carries_a_signature_the_receiver_can_check() {
404 let f = fixture().await;
405 let created = f
406 .create(&f.owner, "https://example.com/hook")
407 .await
408 .expect("should create");
409 let sender = InMemoryWebhookSender::new();
410
411 f.deliver(&[f.event("refs/heads/main", oid("a"), oid("b"))], &sender)
412 .await;
413
414 let sent = sender.sent();
415 assert_eq!(sent.len(), 1);
416 assert_eq!(sent[0].url, "https://example.com/hook");
417
418 let header = |name: &str| {
419 sent[0]
420 .headers
421 .iter()
422 .find(|(key, _)| key == name)
423 .map(|(_, value)| value.clone())
424 };
425
426 assert_eq!(header("Content-Type").as_deref(), Some("application/json"));
427 assert_eq!(header("X-Steid-Event").as_deref(), Some("push"));
428 assert_eq!(
429 header("X-Steid-Signature-256"),
430 Some(created.secret.sign(&sent[0].body)),
431 "the signature must be over the exact bytes sent"
432 );
433}
434
435#[tokio::test]
436async fn a_success_is_recorded_once_and_not_retried() {
437 let f = fixture().await;
438 let created = f
439 .create(&f.owner, "https://example.com/hook")
440 .await
441 .expect("should create");
442
443 f.deliver(
444 &[f.event("refs/heads/main", oid("a"), oid("b"))],
445 &InMemoryWebhookSender::new(),
446 )
447 .await;
448
449 let recorded = f
450 .deliveries
451 .list_by_webhook(&created.webhook.id, 0, 10)
452 .await
453 .expect("list");
454
455 assert_eq!(recorded.len(), 1);
456 assert_eq!(recorded[0].attempt, 1);
457 assert!(recorded[0].ok);
458 // Stamped by the clock the caller handed in, not by one read inside — which is
459 // the whole of what the `Clock` port buys here.
460 assert_eq!(recorded[0].delivered_at, delivered_at().now());
461}
462
463#[tokio::test]
464async fn a_dead_endpoint_is_retried_and_every_attempt_is_recorded() {
465 let f = fixture().await;
466 let created = f
467 .create(&f.owner, "https://example.com/hook")
468 .await
469 .expect("should create");
470 let sender = InMemoryWebhookSender::scripted(vec![outcome(None, "connection refused")]);
471
472 f.deliver(&[f.event("refs/heads/main", oid("a"), oid("b"))], &sender)
473 .await;
474
475 let recorded = f
476 .deliveries
477 .list_by_webhook(&created.webhook.id, 0, 10)
478 .await
479 .expect("list");
480
481 assert_eq!(
482 recorded.len(),
483 INSTANT.len() + 1,
484 "one initial send plus retries"
485 );
486 assert_eq!(sender.sent().len(), INSTANT.len() + 1);
487 assert!(recorded.iter().all(|delivery| !delivery.ok));
488 assert!(
489 recorded
490 .iter()
491 .all(|delivery| delivery.response == "connection refused")
492 );
493}
494
495#[tokio::test]
496async fn retrying_stops_as_soon_as_it_is_accepted() {
497 let f = fixture().await;
498 let created = f
499 .create(&f.owner, "https://example.com/hook")
500 .await
501 .expect("should create");
502 let sender = InMemoryWebhookSender::scripted(vec![
503 outcome(Some(500), "boom"),
504 outcome(Some(204), ""),
505 outcome(Some(500), "should never be reached"),
506 ]);
507
508 f.deliver(&[f.event("refs/heads/main", oid("a"), oid("b"))], &sender)
509 .await;
510
511 let recorded = f
512 .deliveries
513 .list_by_webhook(&created.webhook.id, 0, 10)
514 .await
515 .expect("list");
516
517 assert_eq!(recorded.len(), 2);
518 assert_eq!(sender.sent().len(), 2);
519 assert_eq!(
520 f.list(&f.owner).await.expect("list")[0].last,
521 recorded.first().cloned()
522 );
523}
524
525#[tokio::test]
526async fn a_hook_that_is_off_is_not_delivered_to_but_stays_listed() {
527 let f = fixture().await;
528 let created = f
529 .create(&f.owner, "https://example.com/hook")
530 .await
531 .expect("should create");
532
533 f.webhooks
534 .save(&Webhook {
535 active: false,
536 ..created.webhook.clone()
537 })
538 .await
539 .expect("save");
540
541 let sender = InMemoryWebhookSender::new();
542 f.deliver(&[f.event("refs/heads/main", oid("a"), oid("b"))], &sender)
543 .await;
544
545 assert!(sender.sent().is_empty());
546 assert_eq!(f.list(&f.owner).await.expect("list").len(), 1);
547}
548
549#[tokio::test]
550async fn every_event_of_one_push_reaches_every_hook() {
551 let f = fixture().await;
552 f.create(&f.owner, "https://example.com/one")
553 .await
554 .expect("should create");
555 f.create(&f.owner, "https://example.com/two")
556 .await
557 .expect("should create");
558
559 let sender = InMemoryWebhookSender::new();
560 f.deliver(
561 &[
562 f.event("refs/heads/main", oid("a"), oid("b")),
563 f.event("refs/tags/v1", PushEvent::zero(), oid("c")),
564 ],
565 &sender,
566 )
567 .await;
568
569 let sent = sender.sent();
570 assert_eq!(sent.len(), 4, "two events times two hooks");
571 assert_eq!(sent.iter().filter(|r| r.url.ends_with("/one")).count(), 2);
572}
573
574#[tokio::test]
575async fn a_push_with_no_hooks_costs_nothing() {
576 let f = fixture().await;
577 let sender = InMemoryWebhookSender::new();
578
579 f.deliver(&[f.event("refs/heads/main", oid("a"), oid("b"))], &sender)
580 .await;
581
582 assert!(sender.sent().is_empty());
583}
584
585#[tokio::test]
586async fn a_deletion_carries_no_commits() {
587 let f = fixture().await;
588 f.create(&f.owner, "https://example.com/hook")
589 .await
590 .expect("should create");
591
592 let sender = InMemoryWebhookSender::new();
593 f.deliver(
594 &[f.event("refs/heads/gone", oid("a"), PushEvent::zero())],
595 &sender,
596 )
597 .await;
598
599 let parsed: serde_json::Value =
600 serde_json::from_slice(&sender.sent()[0].body).expect("valid json");
601
602 assert_eq!(parsed["after"], PushEvent::zero().as_str());
603 assert_eq!(parsed["commits"].as_array().map(Vec::len), Some(0));
604}
605
606// --- on and off -----------------------------------------------------------------
607
608#[tokio::test]
609async fn a_hook_that_is_off_is_skipped_but_keeps_its_history() {
610 let f = fixture().await;
611 let created = f
612 .create(&f.owner, "https://example.com/hook")
613 .await
614 .expect("should create");
615
616 let sender = InMemoryWebhookSender::new();
617 f.deliver(&[f.event("refs/heads/main", oid("a"), oid("b"))], &sender)
618 .await;
619
620 f.set_active(&f.owner, &created.webhook.id, false)
621 .await
622 .expect("should turn off");
623
624 f.deliver(&[f.event("refs/heads/main", oid("b"), oid("c"))], &sender)
625 .await;
626
627 assert_eq!(sender.sent().len(), 1, "the second push is not delivered");
628
629 // The point of off rather than delete: the evidence stays readable.
630 let detail = f.view(&f.owner, &created.webhook.id).await.expect("view");
631 assert!(!detail.active);
632 assert_eq!(detail.deliveries.items.len(), 1);
633}
634
635#[tokio::test]
636async fn setting_the_state_it_already_has_changes_nothing() {
637 // A reload of the POST, or a second click: the caller says what it wants rather
638 // than "the other one", so this cannot flip back.
639 let f = fixture().await;
640 let created = f
641 .create(&f.owner, "https://example.com/hook")
642 .await
643 .expect("should create");
644
645 for _ in 0..2 {
646 assert!(
647 !f.set_active(&f.owner, &created.webhook.id, false)
648 .await
649 .expect("should turn off")
650 .active
651 );
652 }
653
654 assert!(
655 f.set_active(&f.owner, &created.webhook.id, true)
656 .await
657 .expect("should turn on")
658 .active
659 );
660}
661
662#[tokio::test]
663async fn only_an_owner_can_turn_a_hook_off() {
664 let f = fixture().await;
665 let created = f
666 .create(&f.owner, "https://example.com/hook")
667 .await
668 .expect("should create");
669
670 for actor in [&f.member, &Actor::Anonymous] {
671 assert!(
672 f.set_active(actor, &created.webhook.id, false)
673 .await
674 .is_err()
675 );
676 }
677
678 assert!(f.list(&f.owner).await.expect("list")[0].active);
679}
680
681// --- redelivering ----------------------------------------------------------------
682
683#[tokio::test]
684async fn a_redelivery_repeats_the_body_and_continues_the_attempt_count() {
685 let f = fixture().await;
686 let created = f
687 .create(&f.owner, "https://example.com/hook")
688 .await
689 .expect("should create");
690
691 let event = f.event("refs/heads/main", oid("a"), oid("b"));
692 f.record(&event).await;
693
694 let sender = InMemoryWebhookSender::new();
695 f.deliver(std::slice::from_ref(&event), &sender).await;
696
697 let first = f
698 .view(&f.owner, &created.webhook.id)
699 .await
700 .expect("view")
701 .deliveries
702 .items[0]
703 .id
704 .clone();
705
706 let repeated = f
707 .again(&f.owner, &created.webhook.id, &first, &sender)
708 .await
709 .expect("should redeliver");
710
711 let sent = sender.sent();
712 assert_eq!(sent.len(), 2);
713 assert_eq!(sent[0].body, sent[1].body, "the same payload, resigned");
714 assert_eq!(sent[0].headers, sent[1].headers);
715
716 // The event's own sequence continues rather than restarting.
717 assert_eq!(repeated.attempt, 2);
718 assert_eq!(
719 f.view(&f.owner, &created.webhook.id)
720 .await
721 .expect("view")
722 .deliveries
723 .items
724 .len(),
725 2
726 );
727}
728
729#[tokio::test]
730async fn a_redelivery_makes_one_attempt_and_does_not_retry() {
731 // Somebody is watching the page. A schedule of retries is what happens to a
732 // delivery nobody asked about.
733 let f = fixture().await;
734 let created = f
735 .create(&f.owner, "https://example.com/hook")
736 .await
737 .expect("should create");
738
739 let event = f.event("refs/heads/main", oid("a"), oid("b"));
740 f.record(&event).await;
741
742 let sender = InMemoryWebhookSender::scripted(vec![outcome(Some(200), "")]);
743 f.deliver(std::slice::from_ref(&event), &sender).await;
744
745 let first = f
746 .view(&f.owner, &created.webhook.id)
747 .await
748 .expect("view")
749 .deliveries
750 .items[0]
751 .id
752 .clone();
753
754 let failing = InMemoryWebhookSender::scripted(vec![outcome(Some(502), "bad gateway")]);
755 let repeated = f
756 .again(&f.owner, &created.webhook.id, &first, &failing)
757 .await
758 .expect("should redeliver");
759
760 assert_eq!(failing.sent().len(), 1);
761 assert!(!repeated.ok);
762 assert_eq!(repeated.response, "bad gateway");
763}
764
765#[tokio::test]
766async fn a_hook_that_is_off_can_still_be_redelivered_to_by_hand() {
767 let f = fixture().await;
768 let created = f
769 .create(&f.owner, "https://example.com/hook")
770 .await
771 .expect("should create");
772
773 let event = f.event("refs/heads/main", oid("a"), oid("b"));
774 f.record(&event).await;
775
776 let sender = InMemoryWebhookSender::new();
777 f.deliver(std::slice::from_ref(&event), &sender).await;
778
779 let first = f
780 .view(&f.owner, &created.webhook.id)
781 .await
782 .expect("view")
783 .deliveries
784 .items[0]
785 .id
786 .clone();
787
788 f.set_active(&f.owner, &created.webhook.id, false)
789 .await
790 .expect("should turn off");
791
792 assert!(
793 f.again(&f.owner, &created.webhook.id, &first, &sender)
794 .await
795 .is_ok()
796 );
797 assert_eq!(sender.sent().len(), 2);
798}
799
800#[tokio::test]
801async fn an_attempt_nobody_recorded_is_not_found_and_nobody_else_may_ask() {
802 let f = fixture().await;
803 let created = f
804 .create(&f.owner, "https://example.com/hook")
805 .await
806 .expect("should create");
807
808 let event = f.event("refs/heads/main", oid("a"), oid("b"));
809 f.record(&event).await;
810
811 let sender = InMemoryWebhookSender::new();
812 f.deliver(std::slice::from_ref(&event), &sender).await;
813
814 let first = f
815 .view(&f.owner, &created.webhook.id)
816 .await
817 .expect("view")
818 .deliveries
819 .items[0]
820 .id
821 .clone();
822
823 assert!(
824 f.again(
825 &f.owner,
826 &created.webhook.id,
827 &WebhookDeliveryId::generate(),
828 &sender
829 )
830 .await
831 .is_err()
832 );
833
834 for actor in [&f.member, &Actor::Anonymous] {
835 assert!(
836 f.again(actor, &created.webhook.id, &first, &sender)
837 .await
838 .is_err()
839 );
840 }
841
842 assert_eq!(sender.sent().len(), 1, "nothing left this machine");
843}