| 1 | use super::*; |
| 2 | use 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 | |
| 20 | |
| 21 | const INSTANT: [Duration; 3] = [Duration::ZERO, Duration::ZERO, Duration::ZERO]; |
| 22 | |
| 23 | |
| 24 | |
| 25 | fn delivered_at() -> FixedClock { |
| 26 | FixedClock::at_epoch_plus(1_700_000_000) |
| 27 | } |
| 28 | |
| 29 | fn oid(prefix: &str) -> ObjectId { |
| 30 | ObjectId::new(format!("{prefix:0>40}")).expect("valid id") |
| 31 | } |
| 32 | |
| 33 | fn 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 | |
| 41 | struct 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 | |
| 58 | async 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 | |
| 70 | |
| 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 | |
| 123 | impl 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 | |
| 197 | |
| 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 | |
| 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 | |
| 265 | |
| 266 | #[tokio::test] |
| 267 | async 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] |
| 285 | async fn a_url_that_would_reach_this_machine_is_refused() { |
| 286 | |
| 287 | |
| 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] |
| 300 | async 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] |
| 318 | async fn a_hook_id_from_another_repository_is_not_found_rather_than_forbidden() { |
| 319 | |
| 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] |
| 334 | async 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 | |
| 349 | |
| 350 | #[test] |
| 351 | fn 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 | |
| 401 | |
| 402 | #[tokio::test] |
| 403 | async 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] |
| 436 | async 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 | |
| 459 | |
| 460 | assert_eq!(recorded[0].delivered_at, delivered_at().now()); |
| 461 | } |
| 462 | |
| 463 | #[tokio::test] |
| 464 | async 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] |
| 496 | async 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] |
| 526 | async 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] |
| 550 | async 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] |
| 575 | async 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] |
| 586 | async 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 | |
| 607 | |
| 608 | #[tokio::test] |
| 609 | async 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 | |
| 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] |
| 636 | async fn setting_the_state_it_already_has_changes_nothing() { |
| 637 | |
| 638 | |
| 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] |
| 663 | async 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 | |
| 682 | |
| 683 | #[tokio::test] |
| 684 | async 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 | |
| 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] |
| 730 | async fn a_redelivery_makes_one_attempt_and_does_not_retry() { |
| 731 | |
| 732 | |
| 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] |
| 766 | async 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] |
| 801 | async 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 | } |