@shipfox/api-integration-jira 10.2.0 → 12.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +53 -0
- package/dist/api/client.js.map +1 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -4
- package/dist/config.js.map +1 -1
- package/dist/core/install.d.ts +22 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +68 -20
- package/dist/core/install.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +15 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +137 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook-registration.d.ts +26 -0
- package/dist/core/webhook-registration.d.ts.map +1 -0
- package/dist/core/webhook-registration.js +165 -0
- package/dist/core/webhook-registration.js.map +1 -0
- package/dist/core/webhook.d.ts +18 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +38 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/installations.d.ts +11 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +54 -5
- package/dist/db/installations.js.map +1 -1
- package/dist/index.d.ts +17 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -5
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +22 -0
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +8 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +108 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/src/api/client.test.ts +106 -0
- package/src/api/client.ts +85 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- package/src/core/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +2 -0
- package/src/core/webhook-processor.test.ts +418 -0
- package/src/core/webhook-processor.ts +166 -0
- package/src/core/webhook-registration.test.ts +375 -0
- package/src/core/webhook-registration.ts +216 -0
- package/src/core/webhook.ts +69 -0
- package/src/db/installations.test.ts +64 -0
- package/src/db/installations.ts +71 -5
- package/src/index.test.ts +8 -0
- package/src/index.ts +102 -3
- package/src/presentation/routes/install.ts +12 -0
- package/src/presentation/routes/webhooks.test.ts +132 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- package/test/env.ts +0 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import {JIRA_WEBHOOK_TTL_MS, registerJiraWebhook} from './webhook-registration.js';
|
|
2
|
+
|
|
3
|
+
const runInlineRegistrationLock = <T>(_lockKey: string, fn: () => Promise<T>) => fn();
|
|
4
|
+
|
|
5
|
+
describe('registerJiraWebhook', () => {
|
|
6
|
+
it('persists the returned id with a 30-day expiry', async () => {
|
|
7
|
+
const registerDynamicWebhook = vi.fn().mockResolvedValue({webhookId: 123});
|
|
8
|
+
const deleteDynamicWebhook = vi.fn().mockResolvedValue(undefined);
|
|
9
|
+
const updateInstallation = vi.fn().mockResolvedValue({id: 'installation-1'});
|
|
10
|
+
const getInstallation = vi.fn().mockResolvedValue(undefined);
|
|
11
|
+
const now = new Date('2030-01-01T00:00:00.000Z');
|
|
12
|
+
|
|
13
|
+
const result = await registerJiraWebhook({
|
|
14
|
+
jira: {registerDynamicWebhook, deleteDynamicWebhook},
|
|
15
|
+
connectionId: 'connection-1',
|
|
16
|
+
cloudId: 'cloud-1',
|
|
17
|
+
accessToken: 'access-token',
|
|
18
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
19
|
+
now: () => now,
|
|
20
|
+
getInstallation,
|
|
21
|
+
updateInstallation,
|
|
22
|
+
withRegistrationLock: (_lockKey, fn) => fn(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(registerDynamicWebhook).toHaveBeenCalledWith({
|
|
26
|
+
accessToken: 'access-token',
|
|
27
|
+
cloudId: 'cloud-1',
|
|
28
|
+
url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
29
|
+
});
|
|
30
|
+
expect(getInstallation).toHaveBeenCalledWith('connection-1');
|
|
31
|
+
expect(updateInstallation).toHaveBeenCalledWith({
|
|
32
|
+
connectionId: 'connection-1',
|
|
33
|
+
webhookIds: [123],
|
|
34
|
+
webhookExpiresAt: new Date(now.getTime() + JIRA_WEBHOOK_TTL_MS),
|
|
35
|
+
});
|
|
36
|
+
expect(result.webhookId).toBe(123);
|
|
37
|
+
expect(deleteDynamicWebhook).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('deletes the remote webhook when local metadata persistence fails', async () => {
|
|
41
|
+
const registerDynamicWebhook = vi.fn().mockResolvedValue({webhookId: 456});
|
|
42
|
+
const deleteDynamicWebhook = vi.fn().mockResolvedValue(undefined);
|
|
43
|
+
const persistenceError = new Error('database unavailable');
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
registerJiraWebhook({
|
|
47
|
+
jira: {registerDynamicWebhook, deleteDynamicWebhook},
|
|
48
|
+
connectionId: 'connection-1',
|
|
49
|
+
cloudId: 'cloud-1',
|
|
50
|
+
accessToken: 'access-token',
|
|
51
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
52
|
+
getInstallation: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
updateInstallation: vi.fn().mockRejectedValue(persistenceError),
|
|
54
|
+
withRegistrationLock: runInlineRegistrationLock,
|
|
55
|
+
}),
|
|
56
|
+
).rejects.toBe(persistenceError);
|
|
57
|
+
|
|
58
|
+
expect(deleteDynamicWebhook).toHaveBeenCalledWith({
|
|
59
|
+
accessToken: 'access-token',
|
|
60
|
+
cloudId: 'cloud-1',
|
|
61
|
+
webhookId: 456,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not mask the metadata error when remote cleanup fails', async () => {
|
|
66
|
+
const persistenceError = new Error('database unavailable');
|
|
67
|
+
const cleanupError = new Error('provider unavailable');
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
registerJiraWebhook({
|
|
71
|
+
jira: {
|
|
72
|
+
registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 789}),
|
|
73
|
+
deleteDynamicWebhook: vi.fn().mockRejectedValue(cleanupError),
|
|
74
|
+
},
|
|
75
|
+
connectionId: 'connection-1',
|
|
76
|
+
cloudId: 'cloud-1',
|
|
77
|
+
accessToken: 'access-token',
|
|
78
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
79
|
+
getInstallation: vi.fn().mockResolvedValue(undefined),
|
|
80
|
+
updateInstallation: vi.fn().mockRejectedValue(persistenceError),
|
|
81
|
+
withRegistrationLock: runInlineRegistrationLock,
|
|
82
|
+
}),
|
|
83
|
+
).rejects.toBe(persistenceError);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('compensates and restores metadata when activation rejects', async () => {
|
|
87
|
+
const activationError = new Error('connection status update failed');
|
|
88
|
+
const previousExpiry = new Date('2030-01-01T00:00:00.000Z');
|
|
89
|
+
const updateInstallation = vi
|
|
90
|
+
.fn()
|
|
91
|
+
.mockResolvedValueOnce({id: 'installation-1'})
|
|
92
|
+
.mockResolvedValueOnce({id: 'installation-1'});
|
|
93
|
+
const deleteDynamicWebhook = vi.fn().mockResolvedValue(undefined);
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
registerJiraWebhook({
|
|
97
|
+
jira: {
|
|
98
|
+
registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 456}),
|
|
99
|
+
deleteDynamicWebhook,
|
|
100
|
+
},
|
|
101
|
+
connectionId: 'connection-1',
|
|
102
|
+
cloudId: 'cloud-1',
|
|
103
|
+
accessToken: 'access-token',
|
|
104
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
105
|
+
getInstallation: vi.fn().mockResolvedValue({
|
|
106
|
+
webhookIds: [123],
|
|
107
|
+
webhookExpiresAt: previousExpiry,
|
|
108
|
+
}),
|
|
109
|
+
updateInstallation,
|
|
110
|
+
withRegistrationLock: runInlineRegistrationLock,
|
|
111
|
+
onRegistrationSuccess: vi.fn().mockRejectedValue(activationError),
|
|
112
|
+
}),
|
|
113
|
+
).rejects.toBe(activationError);
|
|
114
|
+
|
|
115
|
+
expect(deleteDynamicWebhook).toHaveBeenCalledWith({
|
|
116
|
+
accessToken: 'access-token',
|
|
117
|
+
cloudId: 'cloud-1',
|
|
118
|
+
webhookId: 456,
|
|
119
|
+
});
|
|
120
|
+
expect(updateInstallation).toHaveBeenNthCalledWith(2, {
|
|
121
|
+
connectionId: 'connection-1',
|
|
122
|
+
webhookIds: [123],
|
|
123
|
+
webhookExpiresAt: previousExpiry,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('compensates after the lock wrapper rejects while committing a successful registration', async () => {
|
|
128
|
+
const commitError = new Error('transaction commit failed');
|
|
129
|
+
const previousExpiry = new Date('2030-01-01T00:00:00.000Z');
|
|
130
|
+
const updateInstallation = vi
|
|
131
|
+
.fn()
|
|
132
|
+
.mockResolvedValueOnce({id: 'installation-1'})
|
|
133
|
+
.mockResolvedValueOnce({id: 'installation-1'});
|
|
134
|
+
const deleteDynamicWebhook = vi.fn().mockResolvedValue(undefined);
|
|
135
|
+
const withRegistrationLock = async <T>(_lockKey: string, fn: () => Promise<T>) => {
|
|
136
|
+
await fn();
|
|
137
|
+
throw commitError;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
await expect(
|
|
141
|
+
registerJiraWebhook({
|
|
142
|
+
jira: {
|
|
143
|
+
registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 789}),
|
|
144
|
+
deleteDynamicWebhook,
|
|
145
|
+
},
|
|
146
|
+
connectionId: 'connection-1',
|
|
147
|
+
cloudId: 'cloud-1',
|
|
148
|
+
accessToken: 'access-token',
|
|
149
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
150
|
+
getInstallation: vi.fn().mockResolvedValue({
|
|
151
|
+
webhookIds: [123],
|
|
152
|
+
webhookExpiresAt: previousExpiry,
|
|
153
|
+
}),
|
|
154
|
+
updateInstallation,
|
|
155
|
+
withRegistrationLock,
|
|
156
|
+
}),
|
|
157
|
+
).rejects.toBe(commitError);
|
|
158
|
+
|
|
159
|
+
expect(deleteDynamicWebhook).toHaveBeenCalledWith({
|
|
160
|
+
accessToken: 'access-token',
|
|
161
|
+
cloudId: 'cloud-1',
|
|
162
|
+
webhookId: 789,
|
|
163
|
+
});
|
|
164
|
+
expect(deleteDynamicWebhook).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(updateInstallation).toHaveBeenNthCalledWith(2, {
|
|
166
|
+
connectionId: 'connection-1',
|
|
167
|
+
webhookIds: [123],
|
|
168
|
+
webhookExpiresAt: previousExpiry,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('runs the error-state transition before releasing the registration lock', async () => {
|
|
173
|
+
let lockCallbackCompleted = false;
|
|
174
|
+
const withRegistrationLock = async <T>(_lockKey: string, fn: () => Promise<T>) => {
|
|
175
|
+
try {
|
|
176
|
+
return await fn();
|
|
177
|
+
} finally {
|
|
178
|
+
lockCallbackCompleted = true;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const onRegistrationFailure = vi.fn().mockResolvedValue(undefined);
|
|
182
|
+
|
|
183
|
+
await expect(
|
|
184
|
+
registerJiraWebhook({
|
|
185
|
+
jira: {
|
|
186
|
+
registerDynamicWebhook: vi.fn().mockRejectedValue(new Error('provider unavailable')),
|
|
187
|
+
deleteDynamicWebhook: vi.fn(),
|
|
188
|
+
},
|
|
189
|
+
connectionId: 'connection-1',
|
|
190
|
+
cloudId: 'cloud-1',
|
|
191
|
+
accessToken: 'access-token',
|
|
192
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
193
|
+
getInstallation: vi.fn().mockResolvedValue(undefined),
|
|
194
|
+
updateInstallation: vi.fn(),
|
|
195
|
+
withRegistrationLock,
|
|
196
|
+
onRegistrationFailure,
|
|
197
|
+
}),
|
|
198
|
+
).rejects.toThrow('provider unavailable');
|
|
199
|
+
|
|
200
|
+
expect(lockCallbackCompleted).toBe(true);
|
|
201
|
+
expect(onRegistrationFailure).toHaveBeenCalledWith({});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('persists the replacement before deleting all superseded remote webhooks', async () => {
|
|
205
|
+
const calls: string[] = [];
|
|
206
|
+
const updateInstallation = vi.fn().mockImplementation(() => {
|
|
207
|
+
calls.push('persist');
|
|
208
|
+
return Promise.resolve({id: 'installation-1'});
|
|
209
|
+
});
|
|
210
|
+
const deleteDynamicWebhook = vi.fn().mockImplementation(({webhookId}) => {
|
|
211
|
+
calls.push(`delete:${webhookId}`);
|
|
212
|
+
return Promise.resolve();
|
|
213
|
+
});
|
|
214
|
+
const result = await registerJiraWebhook({
|
|
215
|
+
jira: {
|
|
216
|
+
registerDynamicWebhook: vi.fn().mockImplementation(() => {
|
|
217
|
+
calls.push('register');
|
|
218
|
+
return Promise.resolve({webhookId: 456});
|
|
219
|
+
}),
|
|
220
|
+
deleteDynamicWebhook,
|
|
221
|
+
},
|
|
222
|
+
connectionId: 'connection-1',
|
|
223
|
+
cloudId: 'cloud-1',
|
|
224
|
+
accessToken: 'access-token',
|
|
225
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
226
|
+
getInstallation: vi.fn().mockResolvedValue({webhookIds: [123, 234]}),
|
|
227
|
+
updateInstallation,
|
|
228
|
+
withRegistrationLock: runInlineRegistrationLock,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(result.webhookId).toBe(456);
|
|
232
|
+
expect(calls).toEqual(['register', 'persist', 'delete:123', 'delete:234', 'persist']);
|
|
233
|
+
expect(updateInstallation).toHaveBeenNthCalledWith(
|
|
234
|
+
1,
|
|
235
|
+
expect.objectContaining({webhookIds: [456, 123, 234]}),
|
|
236
|
+
);
|
|
237
|
+
expect(updateInstallation).toHaveBeenNthCalledWith(
|
|
238
|
+
2,
|
|
239
|
+
expect.objectContaining({webhookIds: [456]}),
|
|
240
|
+
);
|
|
241
|
+
expect(deleteDynamicWebhook).toHaveBeenCalledTimes(2);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('retains failed superseded webhook cleanup ids for a later retry', async () => {
|
|
245
|
+
const deleteDynamicWebhook = vi
|
|
246
|
+
.fn()
|
|
247
|
+
.mockImplementation(({webhookId}) =>
|
|
248
|
+
webhookId === 234 ? Promise.reject(new Error('provider unavailable')) : Promise.resolve(),
|
|
249
|
+
);
|
|
250
|
+
const updateInstallation = vi
|
|
251
|
+
.fn()
|
|
252
|
+
.mockResolvedValueOnce({id: 'installation-1'})
|
|
253
|
+
.mockResolvedValueOnce({id: 'installation-1'});
|
|
254
|
+
|
|
255
|
+
await expect(
|
|
256
|
+
registerJiraWebhook({
|
|
257
|
+
jira: {
|
|
258
|
+
registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 456}),
|
|
259
|
+
deleteDynamicWebhook,
|
|
260
|
+
},
|
|
261
|
+
connectionId: 'connection-1',
|
|
262
|
+
cloudId: 'cloud-1',
|
|
263
|
+
accessToken: 'access-token',
|
|
264
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
265
|
+
getInstallation: vi.fn().mockResolvedValue({webhookIds: [123, 234]}),
|
|
266
|
+
updateInstallation,
|
|
267
|
+
withRegistrationLock: runInlineRegistrationLock,
|
|
268
|
+
}),
|
|
269
|
+
).resolves.toMatchObject({webhookId: 456});
|
|
270
|
+
|
|
271
|
+
expect(updateInstallation).toHaveBeenNthCalledWith(
|
|
272
|
+
2,
|
|
273
|
+
expect.objectContaining({webhookIds: [456, 234]}),
|
|
274
|
+
);
|
|
275
|
+
expect(deleteDynamicWebhook).toHaveBeenCalledTimes(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('keeps conservative metadata when failed cleanup cannot be persisted', async () => {
|
|
279
|
+
const updateInstallation = vi
|
|
280
|
+
.fn()
|
|
281
|
+
.mockResolvedValueOnce({id: 'installation-1'})
|
|
282
|
+
.mockRejectedValueOnce(new Error('database unavailable'));
|
|
283
|
+
|
|
284
|
+
await expect(
|
|
285
|
+
registerJiraWebhook({
|
|
286
|
+
jira: {
|
|
287
|
+
registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 456}),
|
|
288
|
+
deleteDynamicWebhook: vi
|
|
289
|
+
.fn()
|
|
290
|
+
.mockImplementation(({webhookId}) =>
|
|
291
|
+
webhookId === 234
|
|
292
|
+
? Promise.reject(new Error('provider unavailable'))
|
|
293
|
+
: Promise.resolve(),
|
|
294
|
+
),
|
|
295
|
+
},
|
|
296
|
+
connectionId: 'connection-1',
|
|
297
|
+
cloudId: 'cloud-1',
|
|
298
|
+
accessToken: 'access-token',
|
|
299
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
300
|
+
getInstallation: vi.fn().mockResolvedValue({webhookIds: [123, 234]}),
|
|
301
|
+
updateInstallation,
|
|
302
|
+
withRegistrationLock: runInlineRegistrationLock,
|
|
303
|
+
}),
|
|
304
|
+
).resolves.toMatchObject({webhookId: 456});
|
|
305
|
+
|
|
306
|
+
expect(updateInstallation).toHaveBeenNthCalledWith(
|
|
307
|
+
1,
|
|
308
|
+
expect.objectContaining({webhookIds: [456, 123, 234]}),
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('serializes concurrent replacement and lifecycle transitions per connection', async () => {
|
|
313
|
+
let releaseFirstRegistration!: (error: Error) => void;
|
|
314
|
+
const firstRegistration = new Promise<never>((_, reject) => {
|
|
315
|
+
releaseFirstRegistration = reject;
|
|
316
|
+
});
|
|
317
|
+
const registration = vi
|
|
318
|
+
.fn()
|
|
319
|
+
.mockReturnValueOnce(firstRegistration)
|
|
320
|
+
.mockResolvedValueOnce({webhookId: 456});
|
|
321
|
+
const updateInstallation = vi.fn().mockResolvedValue({id: 'installation-1'});
|
|
322
|
+
const events: string[] = [];
|
|
323
|
+
let lockTail = Promise.resolve();
|
|
324
|
+
const withRegistrationLock = async <T>(_lockKey: string, fn: () => Promise<T>) => {
|
|
325
|
+
const predecessor = lockTail;
|
|
326
|
+
let release!: () => void;
|
|
327
|
+
lockTail = new Promise<void>((resolve) => {
|
|
328
|
+
release = resolve;
|
|
329
|
+
});
|
|
330
|
+
await predecessor;
|
|
331
|
+
try {
|
|
332
|
+
return await fn();
|
|
333
|
+
} finally {
|
|
334
|
+
release();
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const common = {
|
|
339
|
+
jira: {
|
|
340
|
+
registerDynamicWebhook: registration,
|
|
341
|
+
deleteDynamicWebhook: vi.fn().mockResolvedValue(undefined),
|
|
342
|
+
},
|
|
343
|
+
connectionId: 'connection-1',
|
|
344
|
+
cloudId: 'cloud-1',
|
|
345
|
+
accessToken: 'access-token',
|
|
346
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
347
|
+
getInstallation: vi.fn().mockResolvedValue(undefined),
|
|
348
|
+
updateInstallation,
|
|
349
|
+
withRegistrationLock,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const first = registerJiraWebhook({
|
|
353
|
+
...common,
|
|
354
|
+
onRegistrationFailure: () => {
|
|
355
|
+
events.push('first-failure');
|
|
356
|
+
return Promise.resolve();
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
const second = registerJiraWebhook({
|
|
360
|
+
...common,
|
|
361
|
+
onRegistrationSuccess: () => {
|
|
362
|
+
events.push('second-success');
|
|
363
|
+
return Promise.resolve();
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await vi.waitFor(() => expect(registration).toHaveBeenCalledOnce());
|
|
368
|
+
releaseFirstRegistration(new Error('first registration failed'));
|
|
369
|
+
|
|
370
|
+
await expect(first).rejects.toThrow('first registration failed');
|
|
371
|
+
await expect(second).resolves.toMatchObject({webhookId: 456});
|
|
372
|
+
expect(events).toEqual(['first-failure', 'second-success']);
|
|
373
|
+
expect(updateInstallation).toHaveBeenCalledOnce();
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import type {JiraApiClient} from '#api/client.js';
|
|
3
|
+
import {
|
|
4
|
+
getJiraInstallationByConnectionId,
|
|
5
|
+
type JiraInstallationLock,
|
|
6
|
+
updateJiraInstallationWebhook,
|
|
7
|
+
withJiraInstallationLock,
|
|
8
|
+
} from '#db/installations.js';
|
|
9
|
+
|
|
10
|
+
export const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export interface RegisterJiraWebhookParams {
|
|
13
|
+
jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;
|
|
14
|
+
connectionId: string;
|
|
15
|
+
cloudId: string;
|
|
16
|
+
accessToken: string;
|
|
17
|
+
webhookUrl: string;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
getInstallation?: typeof getJiraInstallationByConnectionId;
|
|
20
|
+
updateInstallation?: typeof updateJiraInstallationWebhook;
|
|
21
|
+
withRegistrationLock?: JiraInstallationLock;
|
|
22
|
+
onRegistrationSuccess?: (input: {tx?: unknown}) => Promise<void>;
|
|
23
|
+
onRegistrationFailure?: (input: {tx?: unknown}) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */
|
|
27
|
+
export async function registerJiraWebhook(
|
|
28
|
+
params: RegisterJiraWebhookParams,
|
|
29
|
+
): Promise<{webhookId: number; webhookExpiresAt: Date}> {
|
|
30
|
+
const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;
|
|
31
|
+
let callbackFailed = false;
|
|
32
|
+
let registeredWebhookId: number | undefined;
|
|
33
|
+
let previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>;
|
|
34
|
+
let metadataPersisted = false;
|
|
35
|
+
let cleanupSupersededWebhooks: (() => Promise<void>) | undefined;
|
|
36
|
+
let result: {webhookId: number; webhookExpiresAt: Date};
|
|
37
|
+
try {
|
|
38
|
+
result = await withRegistrationLock(params.cloudId, async () => {
|
|
39
|
+
try {
|
|
40
|
+
const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;
|
|
41
|
+
previous = await getInstallation(params.connectionId);
|
|
42
|
+
const registration = await params.jira.registerDynamicWebhook({
|
|
43
|
+
accessToken: params.accessToken,
|
|
44
|
+
cloudId: params.cloudId,
|
|
45
|
+
url: params.webhookUrl,
|
|
46
|
+
});
|
|
47
|
+
registeredWebhookId = registration.webhookId;
|
|
48
|
+
const now = params.now ?? (() => new Date());
|
|
49
|
+
const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);
|
|
50
|
+
|
|
51
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
52
|
+
const updateInput = {
|
|
53
|
+
connectionId: params.connectionId,
|
|
54
|
+
webhookIds: [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],
|
|
55
|
+
webhookExpiresAt,
|
|
56
|
+
};
|
|
57
|
+
const installation = await updateInstallation(updateInput);
|
|
58
|
+
if (!installation)
|
|
59
|
+
throw new Error('Jira webhook registration lost its installation record');
|
|
60
|
+
metadataPersisted = true;
|
|
61
|
+
await params.onRegistrationSuccess?.({});
|
|
62
|
+
|
|
63
|
+
cleanupSupersededWebhooks = () =>
|
|
64
|
+
finishSupersededWebhookCleanup(
|
|
65
|
+
params,
|
|
66
|
+
previous,
|
|
67
|
+
registration.webhookId,
|
|
68
|
+
webhookExpiresAt,
|
|
69
|
+
);
|
|
70
|
+
return {webhookId: registration.webhookId, webhookExpiresAt};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
callbackFailed = true;
|
|
73
|
+
if (registeredWebhookId !== undefined) {
|
|
74
|
+
const deleted = await deleteNewWebhookAfterPersistenceFailure(
|
|
75
|
+
params,
|
|
76
|
+
registeredWebhookId,
|
|
77
|
+
);
|
|
78
|
+
if (metadataPersisted || !deleted) {
|
|
79
|
+
await restorePreviousWebhookMetadata(
|
|
80
|
+
params,
|
|
81
|
+
previous,
|
|
82
|
+
deleted ? undefined : registeredWebhookId,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
} else if (metadataPersisted) {
|
|
86
|
+
await restorePreviousWebhookMetadata(params, previous);
|
|
87
|
+
}
|
|
88
|
+
await bestEffortRegistrationFailure(params);
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!callbackFailed) {
|
|
94
|
+
if (registeredWebhookId !== undefined) {
|
|
95
|
+
const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
|
|
96
|
+
if (metadataPersisted || !deleted) {
|
|
97
|
+
await restorePreviousWebhookMetadata(
|
|
98
|
+
params,
|
|
99
|
+
previous,
|
|
100
|
+
deleted ? undefined : registeredWebhookId,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
} else if (metadataPersisted) {
|
|
104
|
+
await restorePreviousWebhookMetadata(params, previous);
|
|
105
|
+
}
|
|
106
|
+
await bestEffortRegistrationFailure(params);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (cleanupSupersededWebhooks) {
|
|
112
|
+
try {
|
|
113
|
+
await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger().warn(
|
|
116
|
+
{err: error, connectionId: params.connectionId},
|
|
117
|
+
'Jira superseded webhook cleanup persistence failed',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function finishSupersededWebhookCleanup(
|
|
126
|
+
params: RegisterJiraWebhookParams,
|
|
127
|
+
previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,
|
|
128
|
+
registeredWebhookId: number,
|
|
129
|
+
webhookExpiresAt: Date,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const supersededWebhookIds = (previous?.webhookIds ?? []).filter(
|
|
132
|
+
(webhookId) => webhookId !== registeredWebhookId,
|
|
133
|
+
);
|
|
134
|
+
if (supersededWebhookIds.length === 0) return;
|
|
135
|
+
|
|
136
|
+
const failedCleanupIds: number[] = [];
|
|
137
|
+
for (const webhookId of supersededWebhookIds) {
|
|
138
|
+
try {
|
|
139
|
+
await params.jira.deleteDynamicWebhook({
|
|
140
|
+
accessToken: params.accessToken,
|
|
141
|
+
cloudId: params.cloudId,
|
|
142
|
+
webhookId,
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
failedCleanupIds.push(webhookId);
|
|
146
|
+
logger().warn(
|
|
147
|
+
{err: error, connectionId: params.connectionId, webhookId},
|
|
148
|
+
'Jira superseded webhook cleanup failed',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const retainedWebhookIds = [registeredWebhookId, ...failedCleanupIds];
|
|
154
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
155
|
+
const cleanupMetadata = await updateInstallation({
|
|
156
|
+
connectionId: params.connectionId,
|
|
157
|
+
webhookIds: retainedWebhookIds,
|
|
158
|
+
webhookExpiresAt,
|
|
159
|
+
});
|
|
160
|
+
if (!cleanupMetadata)
|
|
161
|
+
throw new Error('Jira webhook cleanup metadata persistence returned no installation');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function bestEffortRegistrationFailure(params: RegisterJiraWebhookParams): Promise<void> {
|
|
165
|
+
try {
|
|
166
|
+
await params.onRegistrationFailure?.({});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger().warn(
|
|
169
|
+
{err: error, connectionId: params.connectionId},
|
|
170
|
+
'Jira connection error-state update failed after webhook registration rejection',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function restorePreviousWebhookMetadata(
|
|
176
|
+
params: RegisterJiraWebhookParams,
|
|
177
|
+
previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,
|
|
178
|
+
retainedWebhookId?: number,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
182
|
+
await updateInstallation({
|
|
183
|
+
connectionId: params.connectionId,
|
|
184
|
+
webhookIds: [
|
|
185
|
+
...(retainedWebhookId === undefined ? [] : [retainedWebhookId]),
|
|
186
|
+
...(previous?.webhookIds ?? []).filter((webhookId) => webhookId !== retainedWebhookId),
|
|
187
|
+
],
|
|
188
|
+
webhookExpiresAt: previous?.webhookExpiresAt ?? null,
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
logger().warn(
|
|
192
|
+
{err: error, connectionId: params.connectionId},
|
|
193
|
+
'Jira webhook metadata restoration failed after registration rejection',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function deleteNewWebhookAfterPersistenceFailure(
|
|
199
|
+
params: RegisterJiraWebhookParams,
|
|
200
|
+
webhookId: number,
|
|
201
|
+
): Promise<boolean> {
|
|
202
|
+
try {
|
|
203
|
+
await params.jira.deleteDynamicWebhook({
|
|
204
|
+
accessToken: params.accessToken,
|
|
205
|
+
cloudId: params.cloudId,
|
|
206
|
+
webhookId,
|
|
207
|
+
});
|
|
208
|
+
return true;
|
|
209
|
+
} catch (cleanupError) {
|
|
210
|
+
logger().warn(
|
|
211
|
+
{err: cleanupError, connectionId: params.connectionId, webhookId},
|
|
212
|
+
'Jira webhook cleanup failed after metadata persistence rejection',
|
|
213
|
+
);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {JiraWebhookEnvelopeDto} from '@shipfox/api-integration-jira-dto';
|
|
2
|
+
import type {
|
|
3
|
+
IntegrationConnection,
|
|
4
|
+
IntegrationTx,
|
|
5
|
+
PublishIntegrationEventReceivedFn,
|
|
6
|
+
RecordDeliveryOnlyFn,
|
|
7
|
+
} from '@shipfox/api-integration-spi';
|
|
8
|
+
import type {JiraInstallation} from '#db/installations.js';
|
|
9
|
+
|
|
10
|
+
const JIRA_PROVIDER = 'jira';
|
|
11
|
+
|
|
12
|
+
export interface HandleJiraWebhookParams {
|
|
13
|
+
tx: IntegrationTx;
|
|
14
|
+
deliveryId: string;
|
|
15
|
+
receivedAt: string;
|
|
16
|
+
rawPayload: JiraWebhookEnvelopeDto;
|
|
17
|
+
cloudId: string;
|
|
18
|
+
connection: IntegrationConnection<'jira'>;
|
|
19
|
+
authorizingAccountId: string;
|
|
20
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
21
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';
|
|
25
|
+
|
|
26
|
+
export async function handleJiraWebhook(
|
|
27
|
+
params: HandleJiraWebhookParams,
|
|
28
|
+
): Promise<HandleJiraWebhookResult> {
|
|
29
|
+
if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {
|
|
30
|
+
await params.recordDeliveryOnly({
|
|
31
|
+
tx: params.tx,
|
|
32
|
+
provider: JIRA_PROVIDER,
|
|
33
|
+
deliveryId: params.deliveryId,
|
|
34
|
+
});
|
|
35
|
+
return 'discarded';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const payload = {...params.rawPayload, cloudId: params.cloudId};
|
|
39
|
+
const result = await params.publishIntegrationEventReceived({
|
|
40
|
+
tx: params.tx,
|
|
41
|
+
event: {
|
|
42
|
+
provider: JIRA_PROVIDER,
|
|
43
|
+
source: params.connection.slug,
|
|
44
|
+
event: params.rawPayload.webhookEvent,
|
|
45
|
+
workspaceId: params.connection.workspaceId,
|
|
46
|
+
connectionId: params.connection.id,
|
|
47
|
+
connectionName: params.connection.displayName,
|
|
48
|
+
deliveryId: params.deliveryId,
|
|
49
|
+
receivedAt: params.receivedAt,
|
|
50
|
+
payload,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return result.published ? 'published' : 'duplicate';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSelfAuthoredEvent(payload: JiraWebhookEnvelopeDto, authorizingAccountId: string) {
|
|
57
|
+
return (
|
|
58
|
+
(payload.webhookEvent === 'comment_created' ||
|
|
59
|
+
payload.webhookEvent === 'comment_updated' ||
|
|
60
|
+
payload.webhookEvent === 'jira:issue_updated') &&
|
|
61
|
+
payload.user.accountId === authorizingAccountId
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isJiraInstallationUsable(
|
|
66
|
+
installation: JiraInstallation | undefined,
|
|
67
|
+
): installation is JiraInstallation {
|
|
68
|
+
return installation?.status === 'installed';
|
|
69
|
+
}
|