@shipfox/api-integration-sentry 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +192 -0
  5. package/LICENSE +21 -0
  6. package/README.md +145 -0
  7. package/dist/api/client.d.ts +24 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +101 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +9 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +32 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/errors.d.ts +57 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/errors.js +67 -0
  18. package/dist/core/errors.js.map +1 -0
  19. package/dist/core/install.d.ts +63 -0
  20. package/dist/core/install.d.ts.map +1 -0
  21. package/dist/core/install.js +209 -0
  22. package/dist/core/install.js.map +1 -0
  23. package/dist/core/signature.d.ts +7 -0
  24. package/dist/core/signature.d.ts.map +1 -0
  25. package/dist/core/signature.js +6 -0
  26. package/dist/core/signature.js.map +1 -0
  27. package/dist/core/webhook.d.ts +49 -0
  28. package/dist/core/webhook.d.ts.map +1 -0
  29. package/dist/core/webhook.js +175 -0
  30. package/dist/core/webhook.js.map +1 -0
  31. package/dist/db/db.d.ts +327 -0
  32. package/dist/db/db.d.ts.map +1 -0
  33. package/dist/db/db.js +18 -0
  34. package/dist/db/db.js.map +1 -0
  35. package/dist/db/installations.d.ts +74 -0
  36. package/dist/db/installations.d.ts.map +1 -0
  37. package/dist/db/installations.js +120 -0
  38. package/dist/db/installations.js.map +1 -0
  39. package/dist/db/migrations.d.ts +2 -0
  40. package/dist/db/migrations.d.ts.map +1 -0
  41. package/dist/db/migrations.js +5 -0
  42. package/dist/db/migrations.js.map +1 -0
  43. package/dist/db/schema/common.d.ts +2 -0
  44. package/dist/db/schema/common.d.ts.map +1 -0
  45. package/dist/db/schema/common.js +4 -0
  46. package/dist/db/schema/common.js.map +1 -0
  47. package/dist/db/schema/installations.d.ts +165 -0
  48. package/dist/db/schema/installations.d.ts.map +1 -0
  49. package/dist/db/schema/installations.js +42 -0
  50. package/dist/db/schema/installations.js.map +1 -0
  51. package/dist/index.d.ts +36 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +64 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/presentation/dto/integrations.d.ts +15 -0
  56. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  57. package/dist/presentation/dto/integrations.js +9 -0
  58. package/dist/presentation/dto/integrations.js.map +1 -0
  59. package/dist/presentation/routes/errors.d.ts +2 -0
  60. package/dist/presentation/routes/errors.d.ts.map +1 -0
  61. package/dist/presentation/routes/errors.js +51 -0
  62. package/dist/presentation/routes/errors.js.map +1 -0
  63. package/dist/presentation/routes/install.d.ts +16 -0
  64. package/dist/presentation/routes/install.d.ts.map +1 -0
  65. package/dist/presentation/routes/install.js +76 -0
  66. package/dist/presentation/routes/install.js.map +1 -0
  67. package/dist/presentation/routes/installation-handler.d.ts +9 -0
  68. package/dist/presentation/routes/installation-handler.d.ts.map +1 -0
  69. package/dist/presentation/routes/installation-handler.js +70 -0
  70. package/dist/presentation/routes/installation-handler.js.map +1 -0
  71. package/dist/presentation/routes/issue-handler.d.ts +9 -0
  72. package/dist/presentation/routes/issue-handler.d.ts.map +1 -0
  73. package/dist/presentation/routes/issue-handler.js +50 -0
  74. package/dist/presentation/routes/issue-handler.js.map +1 -0
  75. package/dist/presentation/routes/webhook-context.d.ts +20 -0
  76. package/dist/presentation/routes/webhook-context.d.ts.map +1 -0
  77. package/dist/presentation/routes/webhook-context.js +10 -0
  78. package/dist/presentation/routes/webhook-context.js.map +1 -0
  79. package/dist/presentation/routes/webhook-delivery.d.ts +28 -0
  80. package/dist/presentation/routes/webhook-delivery.d.ts.map +1 -0
  81. package/dist/presentation/routes/webhook-delivery.js +56 -0
  82. package/dist/presentation/routes/webhook-delivery.js.map +1 -0
  83. package/dist/presentation/routes/webhook-request.d.ts +10 -0
  84. package/dist/presentation/routes/webhook-request.d.ts.map +1 -0
  85. package/dist/presentation/routes/webhook-request.js +56 -0
  86. package/dist/presentation/routes/webhook-request.js.map +1 -0
  87. package/dist/presentation/routes/webhooks.d.ts +5 -0
  88. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  89. package/dist/presentation/routes/webhooks.js +71 -0
  90. package/dist/presentation/routes/webhooks.js.map +1 -0
  91. package/dist/temporal/activities/index.d.ts +5 -0
  92. package/dist/temporal/activities/index.d.ts.map +1 -0
  93. package/dist/temporal/activities/index.js +8 -0
  94. package/dist/temporal/activities/index.js.map +1 -0
  95. package/dist/temporal/activities/prune-unclaimed-installations.d.ts +4 -0
  96. package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -0
  97. package/dist/temporal/activities/prune-unclaimed-installations.js +11 -0
  98. package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -0
  99. package/dist/temporal/constants.d.ts +2 -0
  100. package/dist/temporal/constants.d.ts.map +1 -0
  101. package/dist/temporal/constants.js +3 -0
  102. package/dist/temporal/constants.js.map +1 -0
  103. package/dist/temporal/workflows/index.d.ts +2 -0
  104. package/dist/temporal/workflows/index.d.ts.map +1 -0
  105. package/dist/temporal/workflows/index.js +3 -0
  106. package/dist/temporal/workflows/index.js.map +1 -0
  107. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts +2 -0
  108. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -0
  109. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +14 -0
  110. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -0
  111. package/dist/tsconfig.test.tsbuildinfo +1 -0
  112. package/drizzle/0000_initial.sql +14 -0
  113. package/drizzle/meta/_journal.json +13 -0
  114. package/drizzle.config.ts +7 -0
  115. package/package.json +70 -0
  116. package/src/api/client.test.ts +147 -0
  117. package/src/api/client.ts +147 -0
  118. package/src/config.test.ts +16 -0
  119. package/src/config.ts +35 -0
  120. package/src/connection-external-url.test.ts +79 -0
  121. package/src/core/errors.ts +81 -0
  122. package/src/core/install.test.ts +343 -0
  123. package/src/core/install.ts +305 -0
  124. package/src/core/signature.test.ts +50 -0
  125. package/src/core/signature.ts +11 -0
  126. package/src/core/webhook.test.ts +118 -0
  127. package/src/core/webhook.ts +246 -0
  128. package/src/db/db.ts +18 -0
  129. package/src/db/installations.test.ts +224 -0
  130. package/src/db/installations.ts +207 -0
  131. package/src/db/migrations.ts +4 -0
  132. package/src/db/schema/common.ts +3 -0
  133. package/src/db/schema/installations.ts +46 -0
  134. package/src/index.test.ts +18 -0
  135. package/src/index.ts +127 -0
  136. package/src/presentation/dto/integrations.ts +9 -0
  137. package/src/presentation/routes/errors.ts +48 -0
  138. package/src/presentation/routes/install.test.ts +325 -0
  139. package/src/presentation/routes/install.ts +102 -0
  140. package/src/presentation/routes/installation-handler.ts +76 -0
  141. package/src/presentation/routes/issue-handler.ts +52 -0
  142. package/src/presentation/routes/webhook-context.ts +25 -0
  143. package/src/presentation/routes/webhook-delivery.ts +61 -0
  144. package/src/presentation/routes/webhook-request.ts +66 -0
  145. package/src/presentation/routes/webhooks.test.ts +773 -0
  146. package/src/presentation/routes/webhooks.ts +59 -0
  147. package/src/temporal/activities/index.ts +7 -0
  148. package/src/temporal/activities/prune-unclaimed-installations.test.ts +52 -0
  149. package/src/temporal/activities/prune-unclaimed-installations.ts +11 -0
  150. package/src/temporal/constants.ts +1 -0
  151. package/src/temporal/workflows/index.ts +1 -0
  152. package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +15 -0
  153. package/test/env.ts +11 -0
  154. package/test/factories/index.ts +9 -0
  155. package/test/factories/sentry-installation-webhook.ts +35 -0
  156. package/test/factories/sentry-installation.ts +44 -0
  157. package/test/factories/sentry-issue-webhook.ts +34 -0
  158. package/test/globalSetup.ts +14 -0
  159. package/test/index.ts +9 -0
  160. package/test/setup.ts +17 -0
  161. package/tsconfig.build.json +9 -0
  162. package/tsconfig.build.tsbuildinfo +1 -0
  163. package/tsconfig.json +3 -0
  164. package/tsconfig.test.json +9 -0
  165. package/vitest.config.ts +12 -0
@@ -0,0 +1,773 @@
1
+ import {createHmac, randomUUID} from 'node:crypto';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {sentryIssueActionSchema} from '@shipfox/api-integration-sentry-dto';
4
+ import {closeApp, createApp} from '@shipfox/node-fastify';
5
+ import {eq} from 'drizzle-orm';
6
+ import type {FastifyInstance} from 'fastify';
7
+ import type {SentryApiClient} from '#api/client.js';
8
+ import {SentryIntegrationProviderError} from '#core/errors.js';
9
+ import {hashAuthorizationCode} from '#core/install.js';
10
+ import {db} from '#db/db.js';
11
+ import {sentryInstallations} from '#db/schema/installations.js';
12
+ import {
13
+ sentryInstallationFactory,
14
+ sentryInstallationWebhookFactory,
15
+ sentryIssueWebhookFactory,
16
+ } from '#test/index.js';
17
+ import {createSentryWebhookRoutes} from './webhooks.js';
18
+
19
+ const CLIENT_SECRET = 'test-client-secret';
20
+ const URL = '/webhooks/integrations/sentry';
21
+
22
+ function sentryClient(overrides: Partial<SentryApiClient> = {}): SentryApiClient {
23
+ return {
24
+ exchangeAuthorizationCode: vi.fn(() =>
25
+ Promise.resolve({token: 'tok', refreshToken: 'refresh', expiresAt: 'x'}),
26
+ ),
27
+ getInstallation: vi.fn(() => Promise.resolve({orgSlug: 'acme'})),
28
+ verifyInstallation: vi.fn(() => Promise.resolve()),
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ // The route persists through injected core functions (publishIntegrationEventReceived,
34
+ // recordDeliveryOnly, getIntegrationConnectionById, updateConnectionLifecycleStatus)
35
+ // that @shipfox/api-integration-core owns and wires in production. sentry only
36
+ // orchestrates them, so here we fake that interface with spies and assert the route's
37
+ // own behavior: signature/payload validation, resource routing, and which core function
38
+ // it calls with what mapped event. The persistence itself (outbox + delivery rows, dedup)
39
+ // is tested in core, against core's own tables. The route's reads/writes of sentry's own
40
+ // `installations` table are exercised for real against sentry's schema.
41
+ function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
42
+ return {
43
+ id: randomUUID(),
44
+ workspaceId: randomUUID(),
45
+ provider: 'sentry',
46
+ externalAccountId: 'install-uuid',
47
+ slug: 'sentry_acme',
48
+ displayName: 'Sentry acme',
49
+ lifecycleStatus: 'active',
50
+ createdAt: new Date(),
51
+ updatedAt: new Date(),
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ interface TestApp {
57
+ app: FastifyInstance;
58
+ sentry: SentryApiClient;
59
+ publishIntegrationEventReceived: ReturnType<typeof vi.fn>;
60
+ recordDeliveryOnly: ReturnType<typeof vi.fn>;
61
+ getIntegrationConnectionById: ReturnType<typeof vi.fn>;
62
+ updateConnectionLifecycleStatus: ReturnType<typeof vi.fn>;
63
+ }
64
+
65
+ async function createTestApp(
66
+ options: {connection?: IntegrationConnection; sentry?: SentryApiClient} = {},
67
+ ): Promise<TestApp> {
68
+ const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
69
+ const recordDeliveryOnly = vi.fn(() => Promise.resolve());
70
+ const getIntegrationConnectionById = vi.fn(() =>
71
+ Promise.resolve(options.connection ?? fakeConnection()),
72
+ );
73
+ const updateConnectionLifecycleStatus = vi.fn(() => Promise.resolve(undefined));
74
+ const sentry = options.sentry ?? sentryClient();
75
+ const routes = createSentryWebhookRoutes({
76
+ sentry,
77
+ coreDb: db,
78
+ publishIntegrationEventReceived,
79
+ recordDeliveryOnly,
80
+ getIntegrationConnectionById,
81
+ updateConnectionLifecycleStatus,
82
+ });
83
+ const app = await createApp({routes: [routes], swagger: false});
84
+ await app.ready();
85
+ return {
86
+ app,
87
+ sentry,
88
+ publishIntegrationEventReceived,
89
+ recordDeliveryOnly,
90
+ getIntegrationConnectionById,
91
+ updateConnectionLifecycleStatus,
92
+ };
93
+ }
94
+
95
+ async function seedInstallation(
96
+ installationUuid: string,
97
+ options: {connectionId?: string | null; status?: 'installed' | 'deleted'; codeHash?: string} = {},
98
+ ): Promise<void> {
99
+ await sentryInstallationFactory.create({
100
+ installationUuid,
101
+ ...(options.connectionId !== undefined && {connectionId: options.connectionId}),
102
+ ...(options.status !== undefined && {status: options.status}),
103
+ ...(options.codeHash !== undefined && {codeHash: options.codeHash}),
104
+ });
105
+ }
106
+
107
+ async function readInstallation(installationUuid: string) {
108
+ const rows = await db()
109
+ .select()
110
+ .from(sentryInstallations)
111
+ .where(eq(sentryInstallations.installationUuid, installationUuid))
112
+ .limit(1);
113
+ return rows[0];
114
+ }
115
+
116
+ function sign(rawBody: string): string {
117
+ return createHmac('sha256', CLIENT_SECRET).update(rawBody).digest('hex');
118
+ }
119
+
120
+ function signedHeaders(
121
+ rawBody: string,
122
+ resource: string,
123
+ deliveryId: string,
124
+ opts: {legacy?: boolean} = {},
125
+ ) {
126
+ const signatureHeader = opts.legacy ? 'sentry-app-signature' : 'sentry-hook-signature';
127
+ return {
128
+ 'content-type': 'application/json',
129
+ 'request-id': deliveryId,
130
+ 'sentry-hook-resource': resource,
131
+ [signatureHeader]: sign(rawBody),
132
+ };
133
+ }
134
+
135
+ describe('Sentry webhook route', () => {
136
+ beforeEach(async () => {
137
+ await closeApp();
138
+ await db().delete(sentryInstallations);
139
+ });
140
+
141
+ afterEach(async () => {
142
+ await closeApp();
143
+ });
144
+
145
+ test('publishes a mapped event for a valid signed issue from a known installation', async () => {
146
+ const installationUuid = 'install-created';
147
+ const connection = fakeConnection();
148
+ await seedInstallation(installationUuid, {connectionId: connection.id});
149
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly, getIntegrationConnectionById} =
150
+ await createTestApp({connection});
151
+ const deliveryId = randomUUID();
152
+ const body = JSON.stringify(
153
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: installationUuid}}),
154
+ );
155
+
156
+ const res = await app.inject({
157
+ method: 'POST',
158
+ url: URL,
159
+ headers: signedHeaders(body, 'issue', deliveryId),
160
+ payload: body,
161
+ });
162
+
163
+ expect(res.statusCode).toBe(204);
164
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
165
+ expect(getIntegrationConnectionById).toHaveBeenCalledTimes(1);
166
+ expect(getIntegrationConnectionById).toHaveBeenCalledWith(
167
+ connection.id,
168
+ expect.objectContaining({tx: expect.anything()}),
169
+ );
170
+ expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
171
+ const call = publishIntegrationEventReceived.mock.calls[0]?.[0];
172
+ expect(call.tx).toBeDefined();
173
+ expect(call.event).toMatchObject({
174
+ source: connection.slug,
175
+ event: 'issue.created',
176
+ deliveryId,
177
+ workspaceId: connection.workspaceId,
178
+ connectionId: connection.id,
179
+ payload: {
180
+ action: 'created',
181
+ issueId: 'issue-123',
182
+ title: 'TypeError: boom',
183
+ webUrl: 'https://sentry.io/organizations/acme/issues/123/',
184
+ issueUrl: 'https://sentry.io/api/0/issues/123/',
185
+ projectUrl: 'https://sentry.io/api/0/projects/acme/web/',
186
+ },
187
+ });
188
+ });
189
+
190
+ describe.each(sentryIssueActionSchema.options)('action "%s"', (action) => {
191
+ test(`publishes event issue.${action}`, async () => {
192
+ const installationUuid = `install-${action}`;
193
+ await seedInstallation(installationUuid);
194
+ const {app, publishIntegrationEventReceived} = await createTestApp();
195
+ const deliveryId = randomUUID();
196
+ const body = JSON.stringify(
197
+ sentryIssueWebhookFactory.build({action, installation: {uuid: installationUuid}}),
198
+ );
199
+
200
+ const res = await app.inject({
201
+ method: 'POST',
202
+ url: URL,
203
+ headers: signedHeaders(body, 'issue', deliveryId),
204
+ payload: body,
205
+ });
206
+
207
+ expect(res.statusCode).toBe(204);
208
+ expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
209
+ expect(publishIntegrationEventReceived.mock.calls[0]?.[0].event).toMatchObject({
210
+ event: `issue.${action}`,
211
+ });
212
+ });
213
+ });
214
+
215
+ test('normalizes a raw "ignored" action to issue.archived', async () => {
216
+ const installationUuid = 'install-ignored';
217
+ await seedInstallation(installationUuid);
218
+ const {app, publishIntegrationEventReceived} = await createTestApp();
219
+ const deliveryId = randomUUID();
220
+ const body = JSON.stringify(
221
+ sentryIssueWebhookFactory.build({action: 'ignored', installation: {uuid: installationUuid}}),
222
+ );
223
+
224
+ const res = await app.inject({
225
+ method: 'POST',
226
+ url: URL,
227
+ headers: signedHeaders(body, 'issue', deliveryId),
228
+ payload: body,
229
+ });
230
+
231
+ expect(res.statusCode).toBe(204);
232
+ expect(publishIntegrationEventReceived.mock.calls[0]?.[0].event).toMatchObject({
233
+ event: 'issue.archived',
234
+ payload: {action: 'archived'},
235
+ });
236
+ });
237
+
238
+ test('falls back the title to "Sentry issue" and maps nullable fields to null', async () => {
239
+ const installationUuid = 'install-minimal';
240
+ await seedInstallation(installationUuid);
241
+ const {app, publishIntegrationEventReceived} = await createTestApp();
242
+ const deliveryId = randomUUID();
243
+ const body = JSON.stringify({
244
+ action: 'created',
245
+ installation: {uuid: installationUuid},
246
+ data: {issue: {id: 'issue-bare'}},
247
+ });
248
+
249
+ const res = await app.inject({
250
+ method: 'POST',
251
+ url: URL,
252
+ headers: signedHeaders(body, 'issue', deliveryId),
253
+ payload: body,
254
+ });
255
+
256
+ expect(res.statusCode).toBe(204);
257
+ expect(publishIntegrationEventReceived.mock.calls[0]?.[0].event.payload).toMatchObject({
258
+ issueId: 'issue-bare',
259
+ title: 'Sentry issue',
260
+ shortId: null,
261
+ culprit: null,
262
+ webUrl: null,
263
+ issueUrl: null,
264
+ projectUrl: null,
265
+ firstSeenAt: null,
266
+ lastSeenAt: null,
267
+ });
268
+ });
269
+
270
+ test('rejects an invalid signature with 401 and persists nothing', async () => {
271
+ const installationUuid = 'install-badsig';
272
+ await seedInstallation(installationUuid);
273
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
274
+ const deliveryId = randomUUID();
275
+ const body = JSON.stringify(
276
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: installationUuid}}),
277
+ );
278
+
279
+ const res = await app.inject({
280
+ method: 'POST',
281
+ url: URL,
282
+ headers: {
283
+ 'content-type': 'application/json',
284
+ 'request-id': deliveryId,
285
+ 'sentry-hook-resource': 'issue',
286
+ 'sentry-hook-signature': sign(`${body}tampered`),
287
+ },
288
+ payload: body,
289
+ });
290
+
291
+ expect(res.statusCode).toBe(401);
292
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
293
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
294
+ });
295
+
296
+ test('rejects a garbage/short signature with 401 (not 500) and persists nothing', async () => {
297
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
298
+ const body = JSON.stringify(
299
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: 'x'}}),
300
+ );
301
+
302
+ const res = await app.inject({
303
+ method: 'POST',
304
+ url: URL,
305
+ headers: {
306
+ 'content-type': 'application/json',
307
+ 'request-id': randomUUID(),
308
+ 'sentry-hook-resource': 'issue',
309
+ 'sentry-hook-signature': 'deadbeef',
310
+ },
311
+ payload: body,
312
+ });
313
+
314
+ expect(res.statusCode).toBe(401);
315
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
316
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
317
+ });
318
+
319
+ test('returns 400 when Request-ID header is missing', async () => {
320
+ const {app} = await createTestApp();
321
+ const body = JSON.stringify(
322
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: 'x'}}),
323
+ );
324
+
325
+ const res = await app.inject({
326
+ method: 'POST',
327
+ url: URL,
328
+ headers: {
329
+ 'content-type': 'application/json',
330
+ 'sentry-hook-resource': 'issue',
331
+ 'sentry-hook-signature': sign(body),
332
+ },
333
+ payload: body,
334
+ });
335
+
336
+ expect(res.statusCode).toBe(400);
337
+ });
338
+
339
+ test('returns 400 when Sentry-Hook-Resource header is missing', async () => {
340
+ const {app} = await createTestApp();
341
+ const body = JSON.stringify(
342
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: 'x'}}),
343
+ );
344
+
345
+ const res = await app.inject({
346
+ method: 'POST',
347
+ url: URL,
348
+ headers: {
349
+ 'content-type': 'application/json',
350
+ 'request-id': randomUUID(),
351
+ 'sentry-hook-signature': sign(body),
352
+ },
353
+ payload: body,
354
+ });
355
+
356
+ expect(res.statusCode).toBe(400);
357
+ });
358
+
359
+ test('returns 401 when both signature headers are missing', async () => {
360
+ const {app} = await createTestApp();
361
+ const body = JSON.stringify(
362
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: 'x'}}),
363
+ );
364
+
365
+ const res = await app.inject({
366
+ method: 'POST',
367
+ url: URL,
368
+ headers: {
369
+ 'content-type': 'application/json',
370
+ 'request-id': randomUUID(),
371
+ 'sentry-hook-resource': 'issue',
372
+ },
373
+ payload: body,
374
+ });
375
+
376
+ expect(res.statusCode).toBe(401);
377
+ });
378
+
379
+ test('accepts a valid signature supplied via the legacy sentry-app-signature header', async () => {
380
+ const installationUuid = 'install-legacy';
381
+ await seedInstallation(installationUuid);
382
+ const {app, publishIntegrationEventReceived} = await createTestApp();
383
+ const deliveryId = randomUUID();
384
+ const body = JSON.stringify(
385
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: installationUuid}}),
386
+ );
387
+
388
+ const res = await app.inject({
389
+ method: 'POST',
390
+ url: URL,
391
+ headers: signedHeaders(body, 'issue', deliveryId, {legacy: true}),
392
+ payload: body,
393
+ });
394
+
395
+ expect(res.statusCode).toBe(204);
396
+ expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
397
+ });
398
+
399
+ test('forwards each delivery to publish (dedup is owned by core)', async () => {
400
+ const installationUuid = 'install-dup';
401
+ await seedInstallation(installationUuid);
402
+ const {app, publishIntegrationEventReceived} = await createTestApp();
403
+ const deliveryId = randomUUID();
404
+ const body = JSON.stringify(
405
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: installationUuid}}),
406
+ );
407
+ const headers = signedHeaders(body, 'issue', deliveryId);
408
+ publishIntegrationEventReceived
409
+ .mockResolvedValueOnce({published: true})
410
+ .mockResolvedValueOnce({published: false});
411
+
412
+ const first = await app.inject({method: 'POST', url: URL, headers, payload: body});
413
+ const second = await app.inject({method: 'POST', url: URL, headers, payload: body});
414
+
415
+ expect(first.statusCode).toBe(204);
416
+ expect(second.statusCode).toBe(204);
417
+ expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(2);
418
+ expect(publishIntegrationEventReceived.mock.calls[0]?.[0].event.deliveryId).toBe(deliveryId);
419
+ expect(publishIntegrationEventReceived.mock.calls[1]?.[0].event.deliveryId).toBe(deliveryId);
420
+ });
421
+
422
+ test('records the delivery only for issues from unknown installations', async () => {
423
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly, getIntegrationConnectionById} =
424
+ await createTestApp();
425
+ const deliveryId = randomUUID();
426
+ const body = JSON.stringify(
427
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: 'unknown-uuid'}}),
428
+ );
429
+
430
+ const res = await app.inject({
431
+ method: 'POST',
432
+ url: URL,
433
+ headers: signedHeaders(body, 'issue', deliveryId),
434
+ payload: body,
435
+ });
436
+
437
+ expect(res.statusCode).toBe(204);
438
+ expect(getIntegrationConnectionById).not.toHaveBeenCalled();
439
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
440
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
441
+ expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'sentry', deliveryId});
442
+ });
443
+
444
+ test('records the delivery only for issues from a deleted installation', async () => {
445
+ const installationUuid = 'install-deleted';
446
+ await seedInstallation(installationUuid, {status: 'deleted'});
447
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly, getIntegrationConnectionById} =
448
+ await createTestApp();
449
+ const deliveryId = randomUUID();
450
+ const body = JSON.stringify(
451
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: installationUuid}}),
452
+ );
453
+
454
+ const res = await app.inject({
455
+ method: 'POST',
456
+ url: URL,
457
+ headers: signedHeaders(body, 'issue', deliveryId),
458
+ payload: body,
459
+ });
460
+
461
+ expect(res.statusCode).toBe(204);
462
+ expect(getIntegrationConnectionById).not.toHaveBeenCalled();
463
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
464
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
465
+ });
466
+
467
+ test('records the delivery only when the installation has no connection', async () => {
468
+ const installationUuid = 'install-noconn';
469
+ await seedInstallation(installationUuid);
470
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly, getIntegrationConnectionById} =
471
+ await createTestApp();
472
+ getIntegrationConnectionById.mockResolvedValue(undefined);
473
+ const deliveryId = randomUUID();
474
+ const body = JSON.stringify(
475
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: installationUuid}}),
476
+ );
477
+
478
+ const res = await app.inject({
479
+ method: 'POST',
480
+ url: URL,
481
+ headers: signedHeaders(body, 'issue', deliveryId),
482
+ payload: body,
483
+ });
484
+
485
+ expect(res.statusCode).toBe(204);
486
+ expect(getIntegrationConnectionById).toHaveBeenCalledTimes(1);
487
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
488
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
489
+ });
490
+
491
+ test('records the delivery only for an issue from a verified-but-unclaimed installation', async () => {
492
+ const installationUuid = 'install-unclaimed';
493
+ await seedInstallation(installationUuid, {connectionId: null});
494
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly, getIntegrationConnectionById} =
495
+ await createTestApp();
496
+ const deliveryId = randomUUID();
497
+ const body = JSON.stringify(
498
+ sentryIssueWebhookFactory.build({action: 'created', installation: {uuid: installationUuid}}),
499
+ );
500
+
501
+ const res = await app.inject({
502
+ method: 'POST',
503
+ url: URL,
504
+ headers: signedHeaders(body, 'issue', deliveryId),
505
+ payload: body,
506
+ });
507
+
508
+ expect(res.statusCode).toBe(204);
509
+ expect(getIntegrationConnectionById).not.toHaveBeenCalled();
510
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
511
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
512
+ });
513
+
514
+ test('records the delivery only for a non-issue, non-installation resource', async () => {
515
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
516
+ const deliveryId = randomUUID();
517
+ const body = JSON.stringify({hello: 'world'});
518
+
519
+ const res = await app.inject({
520
+ method: 'POST',
521
+ url: URL,
522
+ headers: signedHeaders(body, 'event_alert', deliveryId),
523
+ payload: body,
524
+ });
525
+
526
+ expect(res.statusCode).toBe(204);
527
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
528
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
529
+ expect(recordDeliveryOnly.mock.calls[0]?.[0]).toMatchObject({provider: 'sentry', deliveryId});
530
+ });
531
+
532
+ test('records the delivery only for an unknown issue action', async () => {
533
+ const installationUuid = 'install-unknown-action';
534
+ await seedInstallation(installationUuid);
535
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
536
+ const deliveryId = randomUUID();
537
+ const body = JSON.stringify(
538
+ sentryIssueWebhookFactory.build({
539
+ action: 'frobnicated',
540
+ installation: {uuid: installationUuid},
541
+ }),
542
+ );
543
+
544
+ const res = await app.inject({
545
+ method: 'POST',
546
+ url: URL,
547
+ headers: signedHeaders(body, 'issue', deliveryId),
548
+ payload: body,
549
+ });
550
+
551
+ expect(res.statusCode).toBe(204);
552
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
553
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
554
+ });
555
+
556
+ test('records the delivery only for a malformed issue payload', async () => {
557
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
558
+ const deliveryId = randomUUID();
559
+ const body = JSON.stringify({action: 'created', installation: {uuid: 'x'}, data: {}});
560
+
561
+ const res = await app.inject({
562
+ method: 'POST',
563
+ url: URL,
564
+ headers: signedHeaders(body, 'issue', deliveryId),
565
+ payload: body,
566
+ });
567
+
568
+ expect(res.statusCode).toBe(204);
569
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
570
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
571
+ });
572
+
573
+ test('records the delivery only for bad JSON after a valid signature (no 400)', async () => {
574
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
575
+ const body = '{not valid json';
576
+ const deliveryId = randomUUID();
577
+
578
+ const res = await app.inject({
579
+ method: 'POST',
580
+ url: URL,
581
+ headers: signedHeaders(body, 'issue', deliveryId),
582
+ payload: body,
583
+ });
584
+
585
+ expect(res.statusCode).toBe(204);
586
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
587
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
588
+ });
589
+
590
+ test('installation/deleted disables the connection and marks the installation deleted', async () => {
591
+ const installationUuid = 'install-lifecycle-del';
592
+ const connectionId = randomUUID();
593
+ await seedInstallation(installationUuid, {connectionId});
594
+ const {app, recordDeliveryOnly, updateConnectionLifecycleStatus} = await createTestApp();
595
+ const deliveryId = randomUUID();
596
+ const body = JSON.stringify(
597
+ sentryInstallationWebhookFactory.build({
598
+ action: 'deleted',
599
+ data: {installation: {uuid: installationUuid}},
600
+ }),
601
+ );
602
+
603
+ const res = await app.inject({
604
+ method: 'POST',
605
+ url: URL,
606
+ headers: signedHeaders(body, 'installation', deliveryId),
607
+ payload: body,
608
+ });
609
+
610
+ expect(res.statusCode).toBe(204);
611
+ expect((await readInstallation(installationUuid))?.status).toBe('deleted');
612
+ expect(updateConnectionLifecycleStatus).toHaveBeenCalledTimes(1);
613
+ expect(updateConnectionLifecycleStatus).toHaveBeenCalledWith(
614
+ {id: connectionId, lifecycleStatus: 'disabled'},
615
+ expect.objectContaining({tx: expect.anything()}),
616
+ );
617
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
618
+ });
619
+
620
+ test('installation/deleted with no matching row records the delivery and does not disable', async () => {
621
+ const {app, recordDeliveryOnly, updateConnectionLifecycleStatus} = await createTestApp();
622
+ const deliveryId = randomUUID();
623
+ const body = JSON.stringify(
624
+ sentryInstallationWebhookFactory.build({
625
+ action: 'deleted',
626
+ data: {installation: {uuid: 'never-installed'}},
627
+ }),
628
+ );
629
+
630
+ const res = await app.inject({
631
+ method: 'POST',
632
+ url: URL,
633
+ headers: signedHeaders(body, 'installation', deliveryId),
634
+ payload: body,
635
+ });
636
+
637
+ expect(res.statusCode).toBe(204);
638
+ expect(updateConnectionLifecycleStatus).not.toHaveBeenCalled();
639
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
640
+ });
641
+
642
+ test('installation/created exchanges the code and persists a verified-unclaimed row', async () => {
643
+ const installationUuid = 'install-created-webhook';
644
+ const code = 'grant-code-webhook';
645
+ const {app, sentry, recordDeliveryOnly} = await createTestApp();
646
+ const deliveryId = randomUUID();
647
+ const body = JSON.stringify(
648
+ sentryInstallationWebhookFactory.build({
649
+ action: 'created',
650
+ data: {installation: {uuid: installationUuid, code, organization: {slug: 'acme'}}},
651
+ }),
652
+ );
653
+
654
+ const res = await app.inject({
655
+ method: 'POST',
656
+ url: URL,
657
+ headers: signedHeaders(body, 'installation', deliveryId),
658
+ payload: body,
659
+ });
660
+
661
+ expect(res.statusCode).toBe(204);
662
+ expect(sentry.exchangeAuthorizationCode).toHaveBeenCalledWith({installationUuid, code});
663
+ const row = await readInstallation(installationUuid);
664
+ expect(row?.connectionId).toBeNull();
665
+ expect(row?.status).toBe('installed');
666
+ expect(row?.orgSlug).toBe('acme');
667
+ expect(row?.codeHash).toBe(hashAuthorizationCode(code));
668
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
669
+ });
670
+
671
+ test('installation/created reconciles to a no-op when a row already exists', async () => {
672
+ const installationUuid = 'install-created-existing';
673
+ await seedInstallation(installationUuid, {connectionId: null, codeHash: 'pre-existing'});
674
+ const {app, sentry, recordDeliveryOnly} = await createTestApp();
675
+ const deliveryId = randomUUID();
676
+ const body = JSON.stringify(
677
+ sentryInstallationWebhookFactory.build({
678
+ action: 'created',
679
+ data: {
680
+ installation: {uuid: installationUuid, code: 'new-code', organization: {slug: 'acme'}},
681
+ },
682
+ }),
683
+ );
684
+
685
+ const res = await app.inject({
686
+ method: 'POST',
687
+ url: URL,
688
+ headers: signedHeaders(body, 'installation', deliveryId),
689
+ payload: body,
690
+ });
691
+
692
+ expect(res.statusCode).toBe(204);
693
+ expect(sentry.exchangeAuthorizationCode).not.toHaveBeenCalled();
694
+ expect((await readInstallation(installationUuid))?.codeHash).toBe('pre-existing');
695
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
696
+ });
697
+
698
+ test('installation/created without a code records-and-drops without persisting', async () => {
699
+ const installationUuid = 'install-created-nocode';
700
+ const {app, sentry, recordDeliveryOnly} = await createTestApp();
701
+ const deliveryId = randomUUID();
702
+ // Built literally rather than via the factory: the factory always supplies a
703
+ // code and Fishery deep-merges, so an override cannot remove it.
704
+ const body = JSON.stringify({
705
+ action: 'created',
706
+ data: {installation: {uuid: installationUuid, organization: {slug: 'acme'}}},
707
+ });
708
+
709
+ const res = await app.inject({
710
+ method: 'POST',
711
+ url: URL,
712
+ headers: signedHeaders(body, 'installation', deliveryId),
713
+ payload: body,
714
+ });
715
+
716
+ expect(res.statusCode).toBe(204);
717
+ expect(sentry.exchangeAuthorizationCode).not.toHaveBeenCalled();
718
+ expect(await readInstallation(installationUuid)).toBeUndefined();
719
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
720
+ });
721
+
722
+ test('installation/created records-and-drops when the code exchange fails', async () => {
723
+ const installationUuid = 'install-created-exchangefail';
724
+ const {app, recordDeliveryOnly} = await createTestApp({
725
+ sentry: sentryClient({
726
+ exchangeAuthorizationCode: vi.fn(() =>
727
+ Promise.reject(new SentryIntegrationProviderError('access-denied', 'spent')),
728
+ ),
729
+ }),
730
+ });
731
+ const deliveryId = randomUUID();
732
+ const body = JSON.stringify(
733
+ sentryInstallationWebhookFactory.build({
734
+ action: 'created',
735
+ data: {
736
+ installation: {uuid: installationUuid, code: 'spent-code', organization: {slug: 'acme'}},
737
+ },
738
+ }),
739
+ );
740
+
741
+ const res = await app.inject({
742
+ method: 'POST',
743
+ url: URL,
744
+ headers: signedHeaders(body, 'installation', deliveryId),
745
+ payload: body,
746
+ });
747
+
748
+ expect(res.statusCode).toBe(204);
749
+ expect(await readInstallation(installationUuid)).toBeUndefined();
750
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
751
+ });
752
+
753
+ test('records the delivery only for a malformed installation payload', async () => {
754
+ const {app, recordDeliveryOnly} = await createTestApp();
755
+ const deliveryId = randomUUID();
756
+ const body = JSON.stringify(
757
+ sentryInstallationWebhookFactory.build({
758
+ action: 'suspended',
759
+ data: {installation: {uuid: 'x'}},
760
+ }),
761
+ );
762
+
763
+ const res = await app.inject({
764
+ method: 'POST',
765
+ url: URL,
766
+ headers: signedHeaders(body, 'installation', deliveryId),
767
+ payload: body,
768
+ });
769
+
770
+ expect(res.statusCode).toBe(204);
771
+ expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
772
+ });
773
+ });