| 1 | import { randomBytes } from 'node:crypto'; |
| 2 | import { createDatabase, type |
| 3 | import type { ConfigService |
| 4 | import { and, desc |
| 5 | import { afterAll, beforeAll |
| 6 | |
| 7 | import { OrganizationForbiddenError } |
| 8 | import type { WebhookOwner |
| 9 | import type { MailService |
| 10 | import { RepositoryAccessService } |
| 11 | import { WebhookBreakerService } |
| 12 | import { WebhookFanoutService } |
| 13 | import { InvalidWebhookUrlError } |
| 14 | import { WebhooksService } |
| 15 | |
| 16 | const CONNECTION = process |
| 17 | const RUN = Date |
| 18 | const OWNER = ` |
| 19 | const MEMBER = ` |
| 20 | const USERNAME = ` |
| 21 | const ORGANIZATION = ` |
| 22 | const ORGANIZATION_SLUG = ` |
| 23 | const MISSING_DELIVERY = ' |
| 24 | |
| 25 | // Touches only the users and organization it creates; deleting them cascades to their repositories, endpoints and jobs. |
| 26 | describe.skipIf(!CONNECTION |
| 27 | let db: |
| 28 | let pool: |
| 29 | let fanout: |
| 30 | let webhooks: |
| 31 | let repository: |
| 32 | let organizationRepository: |
| 33 | let issue: |
| 34 | let repositoryOwner: |
| 35 | let organizationOwner: |
| 36 | |
| 37 | const jobsFor = |
| 38 | db |
| 39 | .select() |
| 40 | .from(schema.deliveryJob |
| 41 | .where(eq(schema |
| 42 | .orderBy(desc(schema |
| 43 | const eventsFor = |
| 44 | (await jobsFor(endpointId |
| 45 | (job) => |
| 46 | ); |
| 47 | const bodyOf = |
| 48 | JSON.parse((job |
| 49 | const issueOpened = |
| 50 | id: `evt_whk_${repositoryId |
| 51 | type: 'issue.opened' as |
| 52 | repositoryId, |
| 53 | actorId: OWNER, |
| 54 | payload: { issueId |
| 55 | createdAt: new Date |
| 56 | }); |
| 57 | |
| 58 | beforeAll(async () |
| 59 | ({ db, pool |
| 60 | const config = |
| 61 | get: (name: |
| 62 | ({ |
| 63 | WEBHOOK_SECRET_KEY: randomBytes(32) |
| 64 | WEB_APP_URL: 'https://ghost.test', |
| 65 | })[name], |
| 66 | } as unknown as |
| 67 | fanout = new |
| 68 | webhooks = new |
| 69 | db, |
| 70 | new RepositoryAccessService(db |
| 71 | fanout, |
| 72 | config, |
| 73 | ); |
| 74 | |
| 75 | await db.insert |
| 76 | { |
| 77 | id: OWNER, |
| 78 | name: 'Webhook owner', |
| 79 | email: `${USERNAME} |
| 80 | username: USERNAME, |
| 81 | emailVerified: true, |
| 82 | }, |
| 83 | { |
| 84 | id: MEMBER, |
| 85 | name: 'Webhook member', |
| 86 | email: `${USERNAME} |
| 87 | username: `${USERNAME} |
| 88 | emailVerified: true, |
| 89 | }, |
| 90 | ]); |
| 91 | await db.insert |
| 92 | id: ORGANIZATION, |
| 93 | name: 'Webhook org', |
| 94 | slug: ORGANIZATION_SLUG, |
| 95 | createdAt: new Date |
| 96 | }); |
| 97 | await db.insert |
| 98 | { |
| 99 | id: `mem_whk_owner_${RUN |
| 100 | organizationId: ORGANIZATION, |
| 101 | userId: OWNER, |
| 102 | role: 'owner', |
| 103 | createdAt: new Date |
| 104 | }, |
| 105 | { |
| 106 | id: `mem_whk_member_${RUN |
| 107 | organizationId: ORGANIZATION, |
| 108 | userId: MEMBER, |
| 109 | role: 'member', |
| 110 | createdAt: new Date |
| 111 | }, |
| 112 | ]); |
| 113 | [repository, organizationRepository] |
| 114 | .insert(schema.repository |
| 115 | .values([ |
| 116 | { name: 'app', |
| 117 | { |
| 118 | name: 'site', |
| 119 | slug: 'site', |
| 120 | ownerId: OWNER, |
| 121 | organizationId: ORGANIZATION, |
| 122 | visibility: 'public', |
| 123 | }, |
| 124 | ]) |
| 125 | .returning(); |
| 126 | [issue] = await |
| 127 | .insert(schema.issue |
| 128 | .values({ |
| 129 | repositoryId: repository.id |
| 130 | number: 1, |
| 131 | title: 'Bell count', |
| 132 | body: 'The bell should show a count', |
| 133 | authorId: OWNER, |
| 134 | }) |
| 135 | .returning(); |
| 136 | |
| 137 | repositoryOwner = await |
| 138 | username: USERNAME, |
| 139 | repo: 'app', |
| 140 | requesterId: OWNER, |
| 141 | }); |
| 142 | organizationOwner = await |
| 143 | slug: ORGANIZATION_SLUG, |
| 144 | requesterId: OWNER, |
| 145 | }); |
| 146 | }); |
| 147 | |
| 148 | afterAll(async () |
| 149 | await db |
| 150 | .delete(schema.organization |
| 151 | .where(eq(schema |
| 152 | await db |
| 153 | .delete(schema.user |
| 154 | .where(inArray(schema |
| 155 | await pool?.end |
| 156 | }); |
| 157 | |
| 158 | it('creates an endpoint, returns its secret once, and pings it', |
| 159 | const created = |
| 160 | url: 'https://93.184.216.34/hook', |
| 161 | events: ['issue.opened', |
| 162 | }); |
| 163 | expect(created.secret |
| 164 | expect(created.events |
| 165 | |
| 166 | const [stored] |
| 167 | .select({ secret: |
| 168 | .from(schema.webhookEndpoint |
| 169 | .where(eq(schema |
| 170 | expect(stored.secret |
| 171 | expect(stored.secret |
| 172 | |
| 173 | const [ping] |
| 174 | expect(bodyOf(ping |
| 175 | event: 'ping', |
| 176 | webhook: { id: created |
| 177 | repository: { fullName: ` |
| 178 | }); |
| 179 | |
| 180 | const listed = |
| 181 | expect(listed.webhooks |
| 182 | expect(listed.webhooks |
| 183 | }); |
| 184 | |
| 185 | it('refuses a URL that points inside the network', |
| 186 | await expect( |
| 187 | webhooks.create(repositoryOwner |
| 188 | url: 'http://127.0.0.1:9000', |
| 189 | events: ['issue.opened'], |
| 190 | }), |
| 191 | ).rejects.toBeInstanceOf( |
| 192 | }); |
| 193 | |
| 194 | it('keeps one owner out of another owner’s webhooks, and members out of an organization’s', |
| 195 | const created = |
| 196 | url: 'https://93.184.216.34/private', |
| 197 | events: ['issue.opened'], |
| 198 | }); |
| 199 | await expect( |
| 200 | webhooks.deliveries(organizationOwner |
| 201 | ).rejects.toThrow( |
| 202 | await expect( |
| 203 | webhooks.update(repositoryOwner |
| 204 | ).rejects.toBeInstanceOf( |
| 205 | expect( |
| 206 | await webhooks.update |
| 207 | url: 'https://93.184.216.34/moved', |
| 208 | events: ['push', |
| 209 | }), |
| 210 | ).toMatchObject({ url |
| 211 | await expect( |
| 212 | webhooks.ofOrganization({ |
| 213 | ).rejects.toBeInstanceOf( |
| 214 | }); |
| 215 | |
| 216 | it('fans an event out once to the endpoints that chose it', |
| 217 | const wanted = |
| 218 | url: 'https://93.184.216.34/wanted', |
| 219 | events: ['issue.opened'], |
| 220 | }); |
| 221 | const other = |
| 222 | url: 'https://93.184.216.34/other', |
| 223 | events: ['issue.closed'], |
| 224 | }); |
| 225 | const event = |
| 226 | |
| 227 | await fanout.handle |
| 228 | await fanout.handle |
| 229 | |
| 230 | expect(await eventsFor |
| 231 | const [delivery] |
| 232 | expect(bodyOf(delivery |
| 233 | event: 'issue.opened', |
| 234 | repository: { fullName: ` |
| 235 | sender: { username: USERNAME |
| 236 | issue: { |
| 237 | number: 1, |
| 238 | title: 'Bell count', |
| 239 | htmlUrl: `https://ghost.test/${USERNAME |
| 240 | }, |
| 241 | }); |
| 242 | expect(await eventsFor |
| 243 | }); |
| 244 | |
| 245 | it('sends an organization webhook the events of every repository the organization owns', |
| 246 | const created = |
| 247 | url: 'https://93.184.216.34/org', |
| 248 | events: ['issue.opened'], |
| 249 | }); |
| 250 | expect(bodyOf((await |
| 251 | event: 'ping', |
| 252 | organization: { slug: ORGANIZATION_SLUG |
| 253 | }); |
| 254 | const [orgIssue] |
| 255 | .insert(schema.issue |
| 256 | .values({ |
| 257 | repositoryId: organizationRepository.id |
| 258 | number: 1, |
| 259 | title: 'Site is down', |
| 260 | authorId: OWNER, |
| 261 | }) |
| 262 | .returning(); |
| 263 | |
| 264 | await fanout.handle |
| 265 | await fanout.handle |
| 266 | |
| 267 | expect(await eventsFor |
| 268 | const [delivery] |
| 269 | expect(bodyOf(delivery |
| 270 | repository: { fullName: ` |
| 271 | }); |
| 272 | expect( |
| 273 | (await webhooks.list |
| 274 | ).toEqual([created. |
| 275 | }); |
| 276 | |
| 277 | it('describes what each kind of event points at, and skips an event whose rows are gone', |
| 278 | const webhook = |
| 279 | url: 'https://93.184.216.34/kinds', |
| 280 | events: [ |
| 281 | 'issue.commented', |
| 282 | 'issue.assigned', |
| 283 | 'pull_request.reviewed', |
| 284 | 'pull_request.review_commented', |
| 285 | ], |
| 286 | }); |
| 287 | const [pullIssue] |
| 288 | .insert(schema.issue |
| 289 | .values({ |
| 290 | repositoryId: repository.id |
| 291 | number: 2, |
| 292 | title: 'Count the bell', |
| 293 | authorId: OWNER, |
| 294 | isPullRequest: true, |
| 295 | }) |
| 296 | .returning(); |
| 297 | const [pull] |
| 298 | .insert(schema.pullRequest |
| 299 | .values({ |
| 300 | issueId: pullIssue.id |
| 301 | baseRepositoryId: repository.id |
| 302 | baseRef: 'main', |
| 303 | headRepositoryId: repository.id |
| 304 | headRef: 'bell', |
| 305 | headSha: 'b'.repeat |
| 306 | }) |
| 307 | .returning(); |
| 308 | const [comment] |
| 309 | .insert(schema.issueComment |
| 310 | .values({ issueId: |
| 311 | .returning(); |
| 312 | const [review] |
| 313 | .insert(schema.pullRequestReview |
| 314 | .values({ |
| 315 | pullRequestId: pull.id |
| 316 | authorId: OWNER, |
| 317 | state: 'approved', |
| 318 | body: 'Ship it', |
| 319 | commitSha: 'b'.repeat |
| 320 | }) |
| 321 | .returning(); |
| 322 | const [reviewComment] |
| 323 | .insert(schema.pullRequestReviewComment |
| 324 | .values({ |
| 325 | pullRequestId: pull.id |
| 326 | reviewId: review.id |
| 327 | authorId: OWNER, |
| 328 | path: 'src/bell.ts', |
| 329 | side: 'additions', |
| 330 | line: 3, |
| 331 | commitSha: 'b'.repeat |
| 332 | body: 'Nice', |
| 333 | }) |
| 334 | .returning(); |
| 335 | const send = |
| 336 | id: string, |
| 337 | event: Parameters |
| 338 | payload: Record |
| 339 | ) => |
| 340 | fanout.handle({ |
| 341 | id: `evt_whk_${id |
| 342 | type: event, |
| 343 | repositoryId: repository.id |
| 344 | actorId: null, |
| 345 | payload, |
| 346 | createdAt: new Date |
| 347 | } as Parameters< |
| 348 | |
| 349 | await send('commented |
| 350 | issueId: issue.id |
| 351 | commentId: comment.id |
| 352 | }); |
| 353 | await send('assigned |
| 354 | issueId: issue.id |
| 355 | assigneeId: MEMBER, |
| 356 | }); |
| 357 | await send('reviewed |
| 358 | issueId: pullIssue.id |
| 359 | reviewId: review.id |
| 360 | }); |
| 361 | await send('review_commented |
| 362 | issueId: pullIssue.id |
| 363 | commentId: reviewComment.id |
| 364 | }); |
| 365 | // gone before the event was handled |
| 366 | await send('comment_gone |
| 367 | issueId: issue.id |
| 368 | commentId: 'ic_gone', |
| 369 | }); |
| 370 | await send('assignee_gone |
| 371 | issueId: issue.id |
| 372 | assigneeId: 'user_gone', |
| 373 | }); |
| 374 | await send('review_gone |
| 375 | issueId: pullIssue.id |
| 376 | reviewId: 'prr_gone', |
| 377 | }); |
| 378 | await send('review_comment_gone |
| 379 | issueId: pullIssue.id |
| 380 | commentId: 'prc_gone', |
| 381 | }); |
| 382 | await send('issue_gone |
| 383 | issueId: 'issue_gone', |
| 384 | commentId: comment.id |
| 385 | }); |
| 386 | |
| 387 | const bodies = |
| 388 | (await jobsFor(webhook |
| 389 | (job.payload as |
| 390 | bodyOf(job), |
| 391 | ]), |
| 392 | ); |
| 393 | expect(Object.keys |
| 394 | 'issue.assigned', |
| 395 | 'issue.commented', |
| 396 | 'ping', |
| 397 | 'pull_request.review_commented', |
| 398 | 'pull_request.reviewed', |
| 399 | ]); |
| 400 | expect(bodies['issue.commented |
| 401 | sender: null, |
| 402 | issue: { number: 1 |
| 403 | comment: { id: comment |
| 404 | }); |
| 405 | expect(bodies['issue.assigned |
| 406 | assignee: { id: MEMBER |
| 407 | }); |
| 408 | expect(bodies['pull_request.reviewed |
| 409 | pullRequest: { |
| 410 | number: 2, |
| 411 | htmlUrl: `https://ghost.test/${USERNAME |
| 412 | }, |
| 413 | review: { id: review |
| 414 | }); |
| 415 | expect(bodies['pull_request.review_commented |
| 416 | comment: { id: reviewComment |
| 417 | }); |
| 418 | |
| 419 | // an event no endpoint chose queues nothing |
| 420 | const enqueue = |
| 421 | await send('reopened |
| 422 | expect(enqueue).not |
| 423 | enqueue.mockRestore(); |
| 424 | }); |
| 425 | |
| 426 | it('describes labels, releases, forks, members and what a deletion carried', |
| 427 | const webhook = |
| 428 | url: 'https://93.184.216.34/catalog', |
| 429 | events: [ |
| 430 | 'issue.labeled', |
| 431 | 'issue.comment_deleted', |
| 432 | 'label.deleted', |
| 433 | 'release.published', |
| 434 | 'release.deleted', |
| 435 | 'fork.created', |
| 436 | 'member.added', |
| 437 | 'star.created', |
| 438 | 'repository.transferred', |
| 439 | ], |
| 440 | }); |
| 441 | const [label] |
| 442 | .insert(schema.label |
| 443 | .values({ repositoryId: |
| 444 | .returning(); |
| 445 | const [release] |
| 446 | .insert(schema.release |
| 447 | .values({ repositoryId: |
| 448 | .returning(); |
| 449 | const [fork] |
| 450 | .insert(schema.repository |
| 451 | .values({ |
| 452 | name: 'app-fork', |
| 453 | slug: 'app-fork', |
| 454 | ownerId: MEMBER, |
| 455 | visibility: 'public', |
| 456 | parentRepositoryId: repository.id |
| 457 | }) |
| 458 | .returning(); |
| 459 | const send = |
| 460 | id: string, |
| 461 | event: Parameters |
| 462 | payload: Record |
| 463 | ) => |
| 464 | fanout.handle({ |
| 465 | id: `evt_whk_${id |
| 466 | type: event, |
| 467 | repositoryId: repository.id |
| 468 | actorId: MEMBER, |
| 469 | payload, |
| 470 | createdAt: new Date |
| 471 | } as Parameters< |
| 472 | |
| 473 | await send('labeled |
| 474 | issueId: issue.id |
| 475 | labelId: label.id |
| 476 | }); |
| 477 | await send('comment_deleted |
| 478 | issueId: issue.id |
| 479 | comment: { id: ' |
| 480 | }); |
| 481 | await send('label_deleted |
| 482 | label: { |
| 483 | id: 'label_x', |
| 484 | name: 'wontfix', |
| 485 | description: null, |
| 486 | color: 'ffffff', |
| 487 | }, |
| 488 | }); |
| 489 | await send('published |
| 490 | await send('release_deleted |
| 491 | release: { id: ' |
| 492 | }); |
| 493 | await send('forked |
| 494 | await send('member_added |
| 495 | await send('starred |
| 496 | await send('transferred |
| 497 | // gone before the event was handled |
| 498 | await send('label_gone |
| 499 | issueId: issue.id |
| 500 | labelId: 'label_gone', |
| 501 | }); |
| 502 | await send('release_gone |
| 503 | releaseId: 'release_gone', |
| 504 | }); |
| 505 | await send('fork_gone |
| 506 | await send('member_gone |
| 507 | |
| 508 | const bodies = |
| 509 | (await jobsFor(webhook |
| 510 | (job.payload as |
| 511 | bodyOf(job), |
| 512 | ]), |
| 513 | ); |
| 514 | expect(Object.keys |
| 515 | 'fork.created', |
| 516 | 'issue.comment_deleted', |
| 517 | 'issue.labeled', |
| 518 | 'label.deleted', |
| 519 | 'member.added', |
| 520 | 'ping', |
| 521 | 'release.deleted', |
| 522 | 'release.published', |
| 523 | 'repository.transferred', |
| 524 | 'star.created', |
| 525 | ]); |
| 526 | expect(bodies['issue.labeled |
| 527 | issue: { number: 1 |
| 528 | label: { id: label |
| 529 | }); |
| 530 | expect(bodies['issue.comment_deleted |
| 531 | issue: { number: 1 |
| 532 | comment: { id: ' |
| 533 | }); |
| 534 | expect(bodies['label.deleted |
| 535 | id: 'label_x', |
| 536 | name: 'wontfix', |
| 537 | description: null, |
| 538 | color: 'ffffff', |
| 539 | }); |
| 540 | expect(bodies['release.published |
| 541 | id: release.id |
| 542 | tagName: 'v1/rc', |
| 543 | name: 'One', |
| 544 | isDraft: false, |
| 545 | htmlUrl: `https://ghost.test/${USERNAME |
| 546 | }); |
| 547 | expect(bodies['release.deleted |
| 548 | id: 'release_x', |
| 549 | tagName: 'v0', |
| 550 | name: null, |
| 551 | }); |
| 552 | expect(bodies['fork.created |
| 553 | repository: { fullName: ` |
| 554 | fork: { |
| 555 | id: fork.id |
| 556 | fullName: `${USERNAME} |
| 557 | htmlUrl: `https://ghost.test/${USERNAME |
| 558 | }, |
| 559 | }); |
| 560 | expect(bodies['member.added |
| 561 | member: { id: MEMBER |
| 562 | }); |
| 563 | expect(bodies['star.created |
| 564 | sender: { id: MEMBER |
| 565 | repository: { fullName: ` |
| 566 | }); |
| 567 | expect(bodies['repository.transferred |
| 568 | }); |
| 569 | |
| 570 | it('sends a chat service its own message and everyone else Ghost’s JSON', |
| 571 | // inserted directly: saving through the API would resolve these hosts |
| 572 | const [slack, |
| 573 | .insert(schema.webhookEndpoint |
| 574 | .values( |
| 575 | [ |
| 576 | 'https://hooks.slack.com/services/T0/B0/x', |
| 577 | 'https://93.184.216.34/plain', |
| 578 | ].map((url |
| 579 | repositoryId: repository.id |
| 580 | url, |
| 581 | secret: `v1:test-${url |
| 582 | events: ['star.created'], |
| 583 | })), |
| 584 | ) |
| 585 | .returning(); |
| 586 | await fanout.handle |
| 587 | id: `evt_whk_star_${RUN |
| 588 | type: 'star.created', |
| 589 | repositoryId: repository.id |
| 590 | actorId: MEMBER, |
| 591 | payload: {}, |
| 592 | createdAt: new Date |
| 593 | }); |
| 594 | |
| 595 | const [slackJob] |
| 596 | expect(bodyOf(slackJob |
| 597 | username: 'Ghost', |
| 598 | icon_url: 'https://ghost.test/icons/icon-192.png', |
| 599 | text: `[${USERNAME |
| 600 | unfurl_links: false, |
| 601 | }); |
| 602 | const [plainJob] |
| 603 | expect(bodyOf(plainJob |
| 604 | event: 'star.created', |
| 605 | sender: { username: ` |
| 606 | }); |
| 607 | }); |
| 608 | |
| 609 | it('sends a push with its ref and commits', |
| 610 | const webhook = |
| 611 | url: 'https://93.184.216.34/push', |
| 612 | events: ['push'], |
| 613 | }); |
| 614 | const sha = |
| 615 | await fanout.handle |
| 616 | id: `evt_whk_push_${RUN |
| 617 | type: 'push', |
| 618 | repositoryId: repository.id |
| 619 | actorId: OWNER, |
| 620 | payload: { |
| 621 | ref: 'refs/heads/main', |
| 622 | before: '0'.repeat |
| 623 | after: sha, |
| 624 | commits: [ |
| 625 | { |
| 626 | sha, |
| 627 | authorName: 'Owner', |
| 628 | authorEmail: 'owner@example.com', |
| 629 | committedAt: '2026-09-29T12:00:00.000Z', |
| 630 | subject: 'Count the bell', |
| 631 | body: '', |
| 632 | }, |
| 633 | ], |
| 634 | }, |
| 635 | createdAt: new Date |
| 636 | }); |
| 637 | |
| 638 | const [job] |
| 639 | expect(bodyOf(job |
| 640 | event: 'push', |
| 641 | ref: 'refs/heads/main', |
| 642 | created: true, |
| 643 | deleted: false, |
| 644 | repository: { fullName: ` |
| 645 | sender: { username: USERNAME |
| 646 | commits: [{ sha |
| 647 | }); |
| 648 | }); |
| 649 | |
| 650 | it('turns off endpoints that only fail, emails their admins once, and starts fresh when turned back on', |
| 651 | // The sweeps cover every endpoint in the database; only run where all of them are this suite's. |
| 652 | const [others] |
| 653 | .select({ total: |
| 654 | .from(schema.webhookEndpoint |
| 655 | .where( |
| 656 | and( |
| 657 | or( |
| 658 | isNull(schema.webhookEndpoint |
| 659 | ne(schema.webhookEndpoint |
| 660 | ), |
| 661 | or( |
| 662 | isNull(schema.webhookEndpoint |
| 663 | ne(schema.webhookEndpoint |
| 664 | ), |
| 665 | ), |
| 666 | ); |
| 667 | if (others.total |
| 668 | |
| 669 | // earlier tests' endpoints only hold a pending ping; keep them out of this one |
| 670 | await db |
| 671 | .update(schema.webhookEndpoint |
| 672 | .set({ active: |
| 673 | .where( |
| 674 | or( |
| 675 | eq(schema.webhookEndpoint |
| 676 | eq(schema.webhookEndpoint |
| 677 | ), |
| 678 | ); |
| 679 | const endpoint = |
| 680 | const webhook = |
| 681 | url: `https://93.184.216.34/${path |
| 682 | events: ['issue.opened'], |
| 683 | }); |
| 684 | await db |
| 685 | .update(schema.webhookEndpoint |
| 686 | .set({ updatedAt: |
| 687 | .where(eq(schema |
| 688 | return webhook.id |
| 689 | }; |
| 690 | const finish = |
| 691 | db.insert(schema |
| 692 | kind: 'webhook', |
| 693 | idempotencyKey: `webhook:test-breaker-${endpointId |
| 694 | endpointId, |
| 695 | payload: { event: ' |
| 696 | status, |
| 697 | }); |
| 698 | const failing = |
| 699 | await finish(failing |
| 700 | const healthy = |
| 701 | await finish(healthy |
| 702 | await finish(healthy |
| 703 | const failingOrg = |
| 704 | await finish(failingOrg |
| 705 | const gone = |
| 706 | // what apps/delivery does on a 410 |
| 707 | await db |
| 708 | .update(schema.webhookEndpoint |
| 709 | .set({ |
| 710 | active: false, |
| 711 | disabledReason: 'The endpoint responded 410 Gone.', |
| 712 | }) |
| 713 | .where(eq(schema |
| 714 | |
| 715 | const sendWebhookDisabledEmail = |
| 716 | const breaker = |
| 717 | sendWebhookDisabledEmail, |
| 718 | } as unknown as |
| 719 | |
| 720 | const disabled = |
| 721 | expect(disabled.map |
| 722 | [failing, failingOrg] |
| 723 | ); |
| 724 | await breaker.notifyDisabled |
| 725 | await breaker.notifyDisabled |
| 726 | |
| 727 | const emails = |
| 728 | to, |
| 729 | email.owner, |
| 730 | email.webhookId, |
| 731 | email.reason, |
| 732 | ]); |
| 733 | // the organization's owner hears about its endpoint, its plain member does not |
| 734 | expect(emails.sort |
| 735 | [ |
| 736 | [ |
| 737 | `${USERNAME}@example.com`, |
| 738 | `${USERNAME}/app`, |
| 739 | failing, |
| 740 | 'Every delivery failed for three days.', |
| 741 | ], |
| 742 | [ |
| 743 | `${USERNAME}@example.com`, |
| 744 | `${USERNAME}/app`, |
| 745 | gone, |
| 746 | 'The endpoint responded 410 Gone.', |
| 747 | ], |
| 748 | [ |
| 749 | `${USERNAME}@example.com`, |
| 750 | ORGANIZATION_SLUG, |
| 751 | failingOrg, |
| 752 | 'Every delivery failed for three days.', |
| 753 | ], |
| 754 | ].sort(), |
| 755 | ); |
| 756 | const [stillOn] |
| 757 | .select({ active: |
| 758 | .from(schema.webhookEndpoint |
| 759 | .where(eq(schema |
| 760 | expect(stillOn.active |
| 761 | |
| 762 | const reenabled = |
| 763 | active: true, |
| 764 | }); |
| 765 | expect(reenabled).toMatchObject |
| 766 | expect(await breaker |
| 767 | await breaker.notifyDisabled |
| 768 | expect(sendWebhookDisabledEmail).toHaveBeenCalledTimes |
| 769 | |
| 770 | // a failed email is logged and the sweep moves on |
| 771 | await webhooks.update |
| 772 | await db |
| 773 | .update(schema.webhookEndpoint |
| 774 | .set({ |
| 775 | active: false, |
| 776 | disabledReason: 'The endpoint responded 410 Gone.', |
| 777 | }) |
| 778 | .where(eq(schema |
| 779 | sendWebhookDisabledEmail.mockRejectedValueOnce(new |
| 780 | await expect(breaker |
| 781 | }); |
| 782 | |
| 783 | it('lists deliveries with their attempts and redelivers one as a new delivery', |
| 784 | const webhook = |
| 785 | url: 'https://93.184.216.34/again', |
| 786 | events: ['issue.opened'], |
| 787 | }); |
| 788 | await db |
| 789 | .update(schema.deliveryJob |
| 790 | .set({ status: |
| 791 | .where(eq(schema |
| 792 | await db.insert |
| 793 | jobId: (await jobsFor |
| 794 | durationMs: 12, |
| 795 | statusCode: 500, |
| 796 | requestHeaders: { 'X-Ghost-Event': |
| 797 | responseBody: 'boom', |
| 798 | }); |
| 799 | |
| 800 | const { deliveries |
| 801 | repositoryOwner, |
| 802 | webhook.id, |
| 803 | ); |
| 804 | expect(deliveries).toEqual |
| 805 | expect.objectContaining({ |
| 806 | event: 'ping', |
| 807 | status: 'dead', |
| 808 | nextAttemptAt: null, |
| 809 | attempts: [ |
| 810 | expect.objectContaining({ |
| 811 | durationMs: 12, |
| 812 | statusCode: 500, |
| 813 | error: null, |
| 814 | requestHeaders: { 'X-Ghost-Event': |
| 815 | responseBody: 'boom', |
| 816 | }), |
| 817 | ], |
| 818 | }), |
| 819 | ]); |
| 820 | |
| 821 | await webhooks.redeliver |
| 822 | const after = |
| 823 | expect(after.deliveries |
| 824 | expect(after.deliveries |
| 825 | status: 'pending', |
| 826 | body: deliveries[0] |
| 827 | nextAttemptAt: expect.any |
| 828 | attempts: [], |
| 829 | }); |
| 830 | expect(after.deliveries |
| 831 | await expect( |
| 832 | webhooks.redeliver(repositoryOwner |
| 833 | ).rejects.toThrow( |
| 834 | }); |
| 835 | |
| 836 | it('replaces a secret for its own owner only', |
| 837 | const webhook = |
| 838 | url: 'https://93.184.216.34/roll', |
| 839 | events: ['issue.opened'], |
| 840 | }); |
| 841 | const sealed = |
| 842 | ( |
| 843 | await db |
| 844 | .select({ secret: |
| 845 | .from(schema.webhookEndpoint |
| 846 | .where(eq(schema |
| 847 | )[0].secret; |
| 848 | const before = |
| 849 | |
| 850 | const { secret |
| 851 | expect(secret).toMatch |
| 852 | expect(secret).not |
| 853 | expect(await sealed |
| 854 | expect(await sealed |
| 855 | |
| 856 | await expect( |
| 857 | webhooks.rollSecret(organizationOwner |
| 858 | ).rejects.toThrow( |
| 859 | }); |
| 860 | |
| 861 | it('pings and deletes', |
| 862 | const webhook = |
| 863 | url: 'https://93.184.216.34/bye', |
| 864 | events: ['issue.opened'], |
| 865 | }); |
| 866 | await webhooks.ping |
| 867 | expect(await eventsFor |
| 868 | |
| 869 | await webhooks.remove |
| 870 | expect(await jobsFor |
| 871 | await expect(webhooks |
| 872 | 'Webhook not found', |
| 873 | ); |
| 874 | }); |
| 875 | |
| 876 | it('refuses to create webhooks or replace secrets without WEBHOOK_SECRET_KEY', |
| 877 | const unconfigured = |
| 878 | db, |
| 879 | new RepositoryAccessService(db |
| 880 | fanout, |
| 881 | { get: () |
| 882 | ); |
| 883 | await expect( |
| 884 | unconfigured.create(repositoryOwner |
| 885 | url: 'https://93.184.216.34/none', |
| 886 | events: ['issue.opened'], |
| 887 | }), |
| 888 | ).rejects.toThrow( |
| 889 | await expect( |
| 890 | unconfigured.rollSecret(repositoryOwner |
| 891 | ).rejects.toThrow( |
| 892 | }); |
| 893 | }); |