@shipfox/api-integration-jira 12.2.0 → 12.4.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 +8 -7
- package/.turbo/turbo-type$colon$emit.log +0 -1
- package/CHANGELOG.md +17 -0
- package/README.md +5 -1
- package/dist/api/client.d.ts +11 -1
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +57 -12
- package/dist/api/client.js.map +1 -1
- package/dist/core/disconnect.d.ts +12 -0
- package/dist/core/disconnect.d.ts.map +1 -1
- package/dist/core/disconnect.js +45 -1
- package/dist/core/disconnect.js.map +1 -1
- package/dist/core/install.d.ts +1 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +2 -1
- package/dist/core/install.js.map +1 -1
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +2 -2
- package/dist/core/tokens.js.map +1 -1
- package/dist/core/webhook-registration.d.ts +1 -0
- package/dist/core/webhook-registration.d.ts.map +1 -1
- package/dist/core/webhook-registration.js +3 -1
- package/dist/core/webhook-registration.js.map +1 -1
- package/dist/core/webhook-url.d.ts +3 -0
- package/dist/core/webhook-url.d.ts.map +1 -0
- package/dist/core/webhook-url.js +8 -0
- package/dist/core/webhook-url.js.map +1 -0
- package/dist/db/installations.d.ts +13 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +18 -1
- package/dist/db/installations.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -7
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +2 -1
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +2 -1
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/temporal/activities/index.d.ts +8 -1
- package/dist/temporal/activities/index.d.ts.map +1 -1
- package/dist/temporal/activities/index.js +8 -1
- package/dist/temporal/activities/index.js.map +1 -1
- package/dist/temporal/activities/renew-jira-webhooks.d.ts +26 -0
- package/dist/temporal/activities/renew-jira-webhooks.d.ts.map +1 -0
- package/dist/temporal/activities/renew-jira-webhooks.js +96 -0
- package/dist/temporal/activities/renew-jira-webhooks.js.map +1 -0
- package/dist/temporal/constants.d.ts +2 -0
- package/dist/temporal/constants.d.ts.map +1 -1
- package/dist/temporal/constants.js +2 -0
- package/dist/temporal/constants.js.map +1 -1
- package/dist/temporal/worker.d.ts +3 -0
- package/dist/temporal/worker.d.ts.map +1 -1
- package/dist/temporal/worker.js +5 -0
- package/dist/temporal/worker.js.map +1 -1
- package/dist/temporal/workflows/index.bundle.js +38 -2
- package/dist/temporal/workflows/index.d.ts +1 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -1
- package/dist/temporal/workflows/index.js +1 -0
- package/dist/temporal/workflows/index.js.map +1 -1
- package/dist/temporal/workflows/renew-jira-webhooks-cron.d.ts +2 -0
- package/dist/temporal/workflows/renew-jira-webhooks-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/renew-jira-webhooks-cron.js +18 -0
- package/dist/temporal/workflows/renew-jira-webhooks-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/api/client.test.ts +83 -4
- package/src/api/client.ts +70 -8
- package/src/core/disconnect.test.ts +107 -1
- package/src/core/disconnect.ts +64 -1
- package/src/core/install.test.ts +23 -0
- package/src/core/install.ts +5 -2
- package/src/core/tokens-refresh.test.ts +39 -0
- package/src/core/tokens.ts +4 -2
- package/src/core/webhook-registration.test.ts +32 -0
- package/src/core/webhook-registration.ts +4 -1
- package/src/core/webhook-url.ts +9 -0
- package/src/db/installations.test.ts +82 -0
- package/src/db/installations.ts +54 -1
- package/src/index.test.ts +21 -1
- package/src/index.ts +21 -9
- package/src/presentation/routes/webhooks.ts +3 -1
- package/src/temporal/activities/index.ts +20 -3
- package/src/temporal/activities/renew-jira-webhooks.test.ts +215 -0
- package/src/temporal/activities/renew-jira-webhooks.ts +144 -0
- package/src/temporal/constants.ts +2 -0
- package/src/temporal/worker.ts +11 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/renew-jira-webhooks-cron.ts +17 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-jira",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.4.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"ky": "^2.0.0",
|
|
29
29
|
"zod": "^4.4.3",
|
|
30
30
|
"@shipfox/api-auth-context": "12.2.0",
|
|
31
|
-
"@shipfox/api-integration-spi": "1.0
|
|
31
|
+
"@shipfox/api-integration-spi": "1.1.0",
|
|
32
32
|
"@shipfox/api-integration-jira-dto": "12.2.0",
|
|
33
33
|
"@shipfox/config": "1.2.4",
|
|
34
34
|
"@shipfox/node-drizzle": "0.3.5",
|
package/src/api/client.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {mapJiraError} from './client.js';
|
|
|
5
5
|
const mocks = vi.hoisted(() => ({
|
|
6
6
|
delete: vi.fn(),
|
|
7
7
|
post: vi.fn(),
|
|
8
|
+
put: vi.fn(),
|
|
8
9
|
request: vi.fn(),
|
|
9
10
|
warn: vi.fn(),
|
|
10
11
|
}));
|
|
@@ -31,7 +32,11 @@ vi.mock('ky', () => {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
return {
|
|
34
|
-
default: Object.assign(mocks.request, {
|
|
35
|
+
default: Object.assign(mocks.request, {
|
|
36
|
+
delete: mocks.delete,
|
|
37
|
+
post: mocks.post,
|
|
38
|
+
put: mocks.put,
|
|
39
|
+
}),
|
|
35
40
|
HTTPError: MockHTTPError,
|
|
36
41
|
TimeoutError: MockTimeoutError,
|
|
37
42
|
};
|
|
@@ -80,18 +85,22 @@ describe('mapJiraError', () => {
|
|
|
80
85
|
});
|
|
81
86
|
|
|
82
87
|
function resolves(data: unknown) {
|
|
83
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
json: () => Promise.resolve(data),
|
|
90
|
+
text: () => Promise.resolve(data === undefined ? '' : JSON.stringify(data)),
|
|
91
|
+
};
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
describe('Jira dynamic webhook API', () => {
|
|
87
95
|
beforeEach(() => {
|
|
88
96
|
mocks.delete.mockReset();
|
|
89
97
|
mocks.post.mockReset();
|
|
98
|
+
mocks.put.mockReset();
|
|
90
99
|
mocks.request.mockReset();
|
|
91
100
|
mocks.warn.mockReset();
|
|
92
101
|
});
|
|
93
102
|
|
|
94
|
-
it('
|
|
103
|
+
it('sends the six curated events with the configured JQL filter', async () => {
|
|
95
104
|
mocks.post.mockReturnValue(
|
|
96
105
|
resolves({webhookRegistrationResult: [{createdWebhookId: 123, errors: []}]}),
|
|
97
106
|
);
|
|
@@ -121,7 +130,7 @@ describe('Jira dynamic webhook API', () => {
|
|
|
121
130
|
'comment_updated',
|
|
122
131
|
'comment_deleted',
|
|
123
132
|
],
|
|
124
|
-
jqlFilter: '',
|
|
133
|
+
jqlFilter: 'project != null',
|
|
125
134
|
},
|
|
126
135
|
],
|
|
127
136
|
});
|
|
@@ -322,6 +331,76 @@ describe('Jira dynamic webhook API', () => {
|
|
|
322
331
|
}),
|
|
323
332
|
);
|
|
324
333
|
});
|
|
334
|
+
|
|
335
|
+
it('refreshes dynamic webhooks and returns the provider expiration date', async () => {
|
|
336
|
+
const expirationDate = '2030-01-31T00:00:00.000Z';
|
|
337
|
+
mocks.put.mockReturnValue(resolves({expirationDate}));
|
|
338
|
+
const {createJiraApiClient} = await import('./client.js');
|
|
339
|
+
|
|
340
|
+
await expect(
|
|
341
|
+
createJiraApiClient().refreshDynamicWebhooks({
|
|
342
|
+
accessToken: 'access-token',
|
|
343
|
+
cloudId: 'cloud-1',
|
|
344
|
+
webhookIds: [123, 456],
|
|
345
|
+
}),
|
|
346
|
+
).resolves.toEqual(new Date(expirationDate));
|
|
347
|
+
|
|
348
|
+
expect(mocks.put).toHaveBeenCalledWith(
|
|
349
|
+
'http://127.0.0.1:0/ex/jira/cloud-1/rest/api/3/webhook/refresh',
|
|
350
|
+
expect.objectContaining({
|
|
351
|
+
headers: {authorization: 'Bearer access-token'},
|
|
352
|
+
json: {webhookIds: [123, 456]},
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it.each([
|
|
358
|
+
{},
|
|
359
|
+
null,
|
|
360
|
+
undefined,
|
|
361
|
+
])('returns undefined for an empty refresh response: %j', async (response) => {
|
|
362
|
+
mocks.put.mockReturnValue(resolves(response));
|
|
363
|
+
const {createJiraApiClient} = await import('./client.js');
|
|
364
|
+
|
|
365
|
+
await expect(
|
|
366
|
+
createJiraApiClient().refreshDynamicWebhooks({
|
|
367
|
+
accessToken: 'access-token',
|
|
368
|
+
cloudId: 'cloud-1',
|
|
369
|
+
webhookIds: [123],
|
|
370
|
+
}),
|
|
371
|
+
).resolves.toBeUndefined();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it.each([
|
|
375
|
+
[],
|
|
376
|
+
'malformed',
|
|
377
|
+
42,
|
|
378
|
+
false,
|
|
379
|
+
])('rejects a non-object refresh response: %j', async (response) => {
|
|
380
|
+
mocks.put.mockReturnValue(resolves(response));
|
|
381
|
+
const {createJiraApiClient} = await import('./client.js');
|
|
382
|
+
|
|
383
|
+
await expect(
|
|
384
|
+
createJiraApiClient().refreshDynamicWebhooks({
|
|
385
|
+
accessToken: 'access-token',
|
|
386
|
+
cloudId: 'cloud-1',
|
|
387
|
+
webhookIds: [123],
|
|
388
|
+
}),
|
|
389
|
+
).rejects.toMatchObject({reason: 'malformed-provider-response'});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it.each([400, 404])('treats refresh HTTP %i as a signal to re-register', async (status) => {
|
|
393
|
+
mocks.put.mockImplementation(rejectedRequest(status));
|
|
394
|
+
const {createJiraApiClient} = await import('./client.js');
|
|
395
|
+
|
|
396
|
+
await expect(
|
|
397
|
+
createJiraApiClient().refreshDynamicWebhooks({
|
|
398
|
+
accessToken: 'access-token',
|
|
399
|
+
cloudId: 'cloud-1',
|
|
400
|
+
webhookIds: [123],
|
|
401
|
+
}),
|
|
402
|
+
).resolves.toBeUndefined();
|
|
403
|
+
});
|
|
325
404
|
});
|
|
326
405
|
|
|
327
406
|
describe('Jira agent-tools REST API', () => {
|
package/src/api/client.ts
CHANGED
|
@@ -8,7 +8,8 @@ const JIRA_API_TIMEOUT_MS = 10_000;
|
|
|
8
8
|
const SCOPE_SEPARATOR_RE = /[,\s]+/;
|
|
9
9
|
const TRAILING_SLASHES_RE = /\/+$/;
|
|
10
10
|
export const JIRA_DYNAMIC_WEBHOOK_EVENTS = jiraWebhookEventNames;
|
|
11
|
-
|
|
11
|
+
// Jira rejects an empty dynamic-webhook filter; every issue belongs to a project.
|
|
12
|
+
export const JIRA_DYNAMIC_WEBHOOK_JQL = 'project != null';
|
|
12
13
|
|
|
13
14
|
export interface JiraAuthorization {
|
|
14
15
|
accessToken: string;
|
|
@@ -70,6 +71,16 @@ export interface JiraApiClient {
|
|
|
70
71
|
cloudId: string;
|
|
71
72
|
url: string;
|
|
72
73
|
}): Promise<JiraDynamicWebhookRegistration>;
|
|
74
|
+
refreshDynamicWebhooks(input: {
|
|
75
|
+
accessToken: string;
|
|
76
|
+
cloudId: string;
|
|
77
|
+
webhookIds: number[];
|
|
78
|
+
}): Promise<Date | undefined>;
|
|
79
|
+
deleteDynamicWebhooks(input: {
|
|
80
|
+
accessToken: string;
|
|
81
|
+
cloudId: string;
|
|
82
|
+
webhookIds: number[];
|
|
83
|
+
}): Promise<void>;
|
|
73
84
|
deleteDynamicWebhook(input: {
|
|
74
85
|
accessToken: string;
|
|
75
86
|
cloudId: string;
|
|
@@ -102,6 +113,16 @@ interface JiraDynamicWebhookRegistrationResponse {
|
|
|
102
113
|
const JIRA_REFRESH_TOKEN_TERMINAL_ERROR_CODES = new Set(['invalid_grant', 'unauthorized_client']);
|
|
103
114
|
|
|
104
115
|
export function createJiraApiClient(): JiraApiClient {
|
|
116
|
+
const deleteDynamicWebhooks: JiraApiClient['deleteDynamicWebhooks'] = async (input) => {
|
|
117
|
+
await mapJiraError('delete-dynamic-webhooks', () =>
|
|
118
|
+
ky.delete(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
|
|
119
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
120
|
+
json: {webhookIds: input.webhookIds},
|
|
121
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
105
126
|
return {
|
|
106
127
|
async exchangeAuthorizationCode(input) {
|
|
107
128
|
const body = await mapJiraError('exchange-authorization-code', () =>
|
|
@@ -189,14 +210,38 @@ export function createJiraApiClient(): JiraApiClient {
|
|
|
189
210
|
return parseDynamicWebhookRegistration(body);
|
|
190
211
|
},
|
|
191
212
|
|
|
213
|
+
async refreshDynamicWebhooks(input) {
|
|
214
|
+
const response = await mapJiraError('refresh-dynamic-webhooks', async () => {
|
|
215
|
+
try {
|
|
216
|
+
return await ky.put(
|
|
217
|
+
`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook/refresh`,
|
|
218
|
+
{
|
|
219
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
220
|
+
json: {webhookIds: input.webhookIds},
|
|
221
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (
|
|
226
|
+
error instanceof HTTPError &&
|
|
227
|
+
(error.response.status === 400 || error.response.status === 404)
|
|
228
|
+
)
|
|
229
|
+
return undefined;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
if (!response) return undefined;
|
|
234
|
+
return parseDynamicWebhookExpiration(await readJiraResponseBody(response));
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
deleteDynamicWebhooks,
|
|
238
|
+
|
|
192
239
|
async deleteDynamicWebhook(input) {
|
|
193
|
-
await
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}),
|
|
199
|
-
);
|
|
240
|
+
await deleteDynamicWebhooks({
|
|
241
|
+
accessToken: input.accessToken,
|
|
242
|
+
cloudId: input.cloudId,
|
|
243
|
+
webhookIds: [input.webhookId],
|
|
244
|
+
});
|
|
200
245
|
},
|
|
201
246
|
};
|
|
202
247
|
}
|
|
@@ -309,6 +354,23 @@ function parseDynamicWebhookRegistration(
|
|
|
309
354
|
return {webhookId: createdWebhookId};
|
|
310
355
|
}
|
|
311
356
|
|
|
357
|
+
function parseDynamicWebhookExpiration(body: unknown): Date | undefined {
|
|
358
|
+
if (body === undefined || body === null) return undefined;
|
|
359
|
+
if (typeof body !== 'object' || Array.isArray(body)) {
|
|
360
|
+
throw malformed('Jira webhook refresh response was malformed');
|
|
361
|
+
}
|
|
362
|
+
const expirationDate = (body as {expirationDate?: unknown}).expirationDate;
|
|
363
|
+
if (expirationDate === undefined) return undefined;
|
|
364
|
+
if (typeof expirationDate !== 'string') {
|
|
365
|
+
throw malformed('Jira webhook refresh response included a malformed expiration date');
|
|
366
|
+
}
|
|
367
|
+
const parsed = new Date(expirationDate);
|
|
368
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
369
|
+
throw malformed('Jira webhook refresh response included a malformed expiration date');
|
|
370
|
+
}
|
|
371
|
+
return parsed;
|
|
372
|
+
}
|
|
373
|
+
|
|
312
374
|
function parseAuthorization(body: JiraTokenResponse): JiraAuthorization {
|
|
313
375
|
if (typeof body.access_token !== 'string' || body.access_token.length === 0) {
|
|
314
376
|
throw malformed('Jira authorization response did not include an access token');
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
const {getInstallation} = vi.hoisted(() => ({getInstallation: vi.fn()}));
|
|
2
|
+
|
|
1
3
|
vi.mock('#db/installations.js', () => ({
|
|
2
4
|
deleteJiraInstallationByConnectionId: vi.fn().mockResolvedValue(true),
|
|
5
|
+
getJiraInstallationByConnectionId: getInstallation,
|
|
3
6
|
}));
|
|
4
7
|
|
|
5
|
-
import {
|
|
8
|
+
import type {JiraInstallation} from '#db/installations.js';
|
|
9
|
+
import {deregisterJiraWebhooks, disconnectJiraInstallation} from './disconnect.js';
|
|
6
10
|
|
|
7
11
|
describe('disconnectJiraInstallation', () => {
|
|
8
12
|
it('deletes secrets, installation, and connection when the connection exists', async () => {
|
|
@@ -21,4 +25,106 @@ describe('disconnectJiraInstallation', () => {
|
|
|
21
25
|
expect(deleteSecrets).toHaveBeenCalledOnce();
|
|
22
26
|
expect(deleteConnection).toHaveBeenCalledOnce();
|
|
23
27
|
});
|
|
28
|
+
|
|
29
|
+
it('deregisters webhooks before deletion and continues when the hook fails', async () => {
|
|
30
|
+
const deleteSecrets = vi.fn().mockResolvedValue(1);
|
|
31
|
+
const deleteConnection = vi.fn().mockResolvedValue(true);
|
|
32
|
+
const deregisterWebhooks = vi.fn().mockRejectedValue(new Error('Jira unavailable'));
|
|
33
|
+
const transaction = vi.fn(async (fn) => await fn({}));
|
|
34
|
+
|
|
35
|
+
await disconnectJiraInstallation({
|
|
36
|
+
connectionId: crypto.randomUUID(),
|
|
37
|
+
getConnection: vi.fn().mockResolvedValue({workspaceId: crypto.randomUUID()}),
|
|
38
|
+
deleteSecrets,
|
|
39
|
+
deregisterWebhooks,
|
|
40
|
+
transaction,
|
|
41
|
+
deleteConnection,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(deregisterWebhooks).toHaveBeenCalledOnce();
|
|
45
|
+
expect(deleteSecrets).toHaveBeenCalledOnce();
|
|
46
|
+
expect(deleteConnection).toHaveBeenCalledOnce();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uses a fresh access token and the stored webhook ids for deregistration', async () => {
|
|
50
|
+
const connectionId = crypto.randomUUID();
|
|
51
|
+
const getAccessToken = vi.fn().mockResolvedValue('fresh-access-token');
|
|
52
|
+
const deleteDynamicWebhooks = vi.fn().mockResolvedValue(undefined);
|
|
53
|
+
|
|
54
|
+
await deregisterJiraWebhooks({
|
|
55
|
+
connectionId,
|
|
56
|
+
tokenStore: {getAccessToken},
|
|
57
|
+
jira: {deleteDynamicWebhooks},
|
|
58
|
+
getInstallation: vi.fn().mockResolvedValue({
|
|
59
|
+
cloudId: 'cloud-1',
|
|
60
|
+
webhookIds: [123, 456],
|
|
61
|
+
} as JiraInstallation),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(getAccessToken).toHaveBeenCalledWith({connectionId, allowInactive: true});
|
|
65
|
+
expect(deleteDynamicWebhooks).toHaveBeenCalledWith({
|
|
66
|
+
accessToken: 'fresh-access-token',
|
|
67
|
+
cloudId: 'cloud-1',
|
|
68
|
+
webhookIds: [123, 456],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('uses the database installation lookup by default', async () => {
|
|
73
|
+
const connectionId = crypto.randomUUID();
|
|
74
|
+
const getAccessToken = vi.fn().mockResolvedValue('fresh-access-token');
|
|
75
|
+
const deleteDynamicWebhooks = vi.fn().mockResolvedValue(undefined);
|
|
76
|
+
getInstallation.mockResolvedValue({
|
|
77
|
+
cloudId: 'cloud-1',
|
|
78
|
+
webhookIds: [123],
|
|
79
|
+
} as JiraInstallation);
|
|
80
|
+
|
|
81
|
+
await deregisterJiraWebhooks({
|
|
82
|
+
connectionId,
|
|
83
|
+
tokenStore: {getAccessToken},
|
|
84
|
+
jira: {deleteDynamicWebhooks},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(getInstallation).toHaveBeenCalledWith(connectionId);
|
|
88
|
+
expect(deleteDynamicWebhooks).toHaveBeenCalledWith({
|
|
89
|
+
accessToken: 'fresh-access-token',
|
|
90
|
+
cloudId: 'cloud-1',
|
|
91
|
+
webhookIds: [123],
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each([
|
|
96
|
+
{name: 'the installation is gone', installation: undefined},
|
|
97
|
+
{name: 'no webhook ids are stored', installation: {cloudId: 'cloud-1', webhookIds: []}},
|
|
98
|
+
])('skips deregistration and token minting when $name', async ({installation}) => {
|
|
99
|
+
const getAccessToken = vi.fn();
|
|
100
|
+
const deleteDynamicWebhooks = vi.fn();
|
|
101
|
+
|
|
102
|
+
await deregisterJiraWebhooks({
|
|
103
|
+
connectionId: crypto.randomUUID(),
|
|
104
|
+
tokenStore: {getAccessToken},
|
|
105
|
+
jira: {deleteDynamicWebhooks},
|
|
106
|
+
getInstallation: vi.fn().mockResolvedValue(installation as JiraInstallation | undefined),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(getAccessToken).not.toHaveBeenCalled();
|
|
110
|
+
expect(deleteDynamicWebhooks).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('swallows a rejected Jira deletion so the disconnect can proceed', async () => {
|
|
114
|
+
const deleteDynamicWebhooks = vi.fn().mockRejectedValue(new Error('Jira unavailable'));
|
|
115
|
+
|
|
116
|
+
await expect(
|
|
117
|
+
deregisterJiraWebhooks({
|
|
118
|
+
connectionId: crypto.randomUUID(),
|
|
119
|
+
tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
|
|
120
|
+
jira: {deleteDynamicWebhooks},
|
|
121
|
+
getInstallation: vi.fn().mockResolvedValue({
|
|
122
|
+
cloudId: 'cloud-1',
|
|
123
|
+
webhookIds: [123],
|
|
124
|
+
} as JiraInstallation),
|
|
125
|
+
}),
|
|
126
|
+
).resolves.toBeUndefined();
|
|
127
|
+
|
|
128
|
+
expect(deleteDynamicWebhooks).toHaveBeenCalledOnce();
|
|
129
|
+
});
|
|
24
130
|
});
|
package/src/core/disconnect.ts
CHANGED
|
@@ -1,10 +1,65 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import type {JiraApiClient} from '#api/client.js';
|
|
3
|
+
import {
|
|
4
|
+
deleteJiraInstallationByConnectionId,
|
|
5
|
+
getJiraInstallationByConnectionId,
|
|
6
|
+
type JiraInstallation,
|
|
7
|
+
} from '#db/installations.js';
|
|
8
|
+
import type {JiraTokenStore} from './tokens.js';
|
|
2
9
|
import {jiraSecretsNamespace} from './tokens.js';
|
|
3
10
|
|
|
11
|
+
export interface DeregisterJiraWebhooksParams {
|
|
12
|
+
connectionId: string;
|
|
13
|
+
tokenStore: Pick<JiraTokenStore, 'getAccessToken'>;
|
|
14
|
+
jira: Pick<JiraApiClient, 'deleteDynamicWebhooks'>;
|
|
15
|
+
getInstallation?: (connectionId: string) => Promise<JiraInstallation | undefined>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function prepareJiraWebhookDeregistration(
|
|
19
|
+
params: DeregisterJiraWebhooksParams,
|
|
20
|
+
): Promise<(() => Promise<void>) | undefined> {
|
|
21
|
+
try {
|
|
22
|
+
const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;
|
|
23
|
+
const installation = await getInstallation(params.connectionId);
|
|
24
|
+
if (!installation || installation.webhookIds.length === 0) return undefined;
|
|
25
|
+
|
|
26
|
+
const accessToken = await params.tokenStore.getAccessToken({
|
|
27
|
+
connectionId: params.connectionId,
|
|
28
|
+
allowInactive: true,
|
|
29
|
+
});
|
|
30
|
+
return async () => {
|
|
31
|
+
try {
|
|
32
|
+
await params.jira.deleteDynamicWebhooks({
|
|
33
|
+
accessToken,
|
|
34
|
+
cloudId: installation.cloudId,
|
|
35
|
+
webhookIds: installation.webhookIds,
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger().warn(
|
|
39
|
+
{err: error, connectionId: params.connectionId},
|
|
40
|
+
'Jira webhook deregistration failed during disconnect',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
} catch (error) {
|
|
45
|
+
logger().warn(
|
|
46
|
+
{err: error, connectionId: params.connectionId},
|
|
47
|
+
'Jira webhook deregistration preparation failed during disconnect',
|
|
48
|
+
);
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function deregisterJiraWebhooks(params: DeregisterJiraWebhooksParams): Promise<void> {
|
|
54
|
+
const cleanup = await prepareJiraWebhookDeregistration(params);
|
|
55
|
+
await cleanup?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
4
58
|
export interface DisconnectJiraInstallationParams<Tx = unknown> {
|
|
5
59
|
connectionId: string;
|
|
6
60
|
getConnection(connectionId: string): Promise<{workspaceId: string} | undefined>;
|
|
7
61
|
deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
|
|
62
|
+
deregisterWebhooks?: (() => Promise<void>) | undefined;
|
|
8
63
|
transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
|
|
9
64
|
deleteConnection(params: {connectionId: string}, options: {tx: Tx}): Promise<boolean>;
|
|
10
65
|
}
|
|
@@ -13,6 +68,14 @@ export async function disconnectJiraInstallation<Tx = unknown>(
|
|
|
13
68
|
params: DisconnectJiraInstallationParams<Tx>,
|
|
14
69
|
): Promise<void> {
|
|
15
70
|
const connection = await params.getConnection(params.connectionId);
|
|
71
|
+
try {
|
|
72
|
+
await params.deregisterWebhooks?.();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
logger().warn(
|
|
75
|
+
{err: error, connectionId: params.connectionId},
|
|
76
|
+
'Jira webhook deregistration hook failed during disconnect',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
16
79
|
if (connection)
|
|
17
80
|
await params.deleteSecrets({
|
|
18
81
|
workspaceId: connection.workspaceId,
|
package/src/core/install.test.ts
CHANGED
|
@@ -21,6 +21,8 @@ function createParams() {
|
|
|
21
21
|
getMyself: vi.fn().mockResolvedValue({accountId: 'account-1'}),
|
|
22
22
|
refreshAccessToken: vi.fn(),
|
|
23
23
|
registerDynamicWebhook: vi.fn(),
|
|
24
|
+
refreshDynamicWebhooks: vi.fn(),
|
|
25
|
+
deleteDynamicWebhooks: vi.fn(),
|
|
24
26
|
deleteDynamicWebhook: vi.fn(),
|
|
25
27
|
};
|
|
26
28
|
const tokenStore = {storeTokens: vi.fn().mockResolvedValue(undefined)};
|
|
@@ -190,6 +192,27 @@ describe('Jira OAuth installation', () => {
|
|
|
190
192
|
expect(params.disconnectJiraInstallation).not.toHaveBeenCalled();
|
|
191
193
|
});
|
|
192
194
|
|
|
195
|
+
it('compensates a new connection without reacquiring the installation lock', async () => {
|
|
196
|
+
const params = createParams();
|
|
197
|
+
params.jira.getAccessibleResources.mockResolvedValue([
|
|
198
|
+
{
|
|
199
|
+
cloudId: 'cloud-1',
|
|
200
|
+
name: 'Acme',
|
|
201
|
+
url: 'https://acme.atlassian.net',
|
|
202
|
+
scopes: ['read:jira-work'],
|
|
203
|
+
},
|
|
204
|
+
]);
|
|
205
|
+
const storageError = new Error('secret storage unavailable');
|
|
206
|
+
params.tokenStore.storeTokens.mockRejectedValue(storageError);
|
|
207
|
+
|
|
208
|
+
await expect(handleJiraCallback(params)).rejects.toBe(storageError);
|
|
209
|
+
|
|
210
|
+
expect(params.disconnectJiraInstallation).toHaveBeenCalledWith({
|
|
211
|
+
connectionId: 'connection-1',
|
|
212
|
+
lockAlreadyHeld: true,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
193
216
|
it('falls back to a second error-state write when the registration callback cannot persist it', async () => {
|
|
194
217
|
const params = createParams();
|
|
195
218
|
params.jira.getAccessibleResources.mockResolvedValue([
|
package/src/core/install.ts
CHANGED
|
@@ -58,7 +58,10 @@ export interface HandleJiraCallbackParams {
|
|
|
58
58
|
withJiraInstallationLock?: JiraInstallationLock;
|
|
59
59
|
markConnectionActive(input: {connectionId: string; tx?: unknown}): Promise<void>;
|
|
60
60
|
markConnectionError(input: {connectionId: string; tx?: unknown}): Promise<void>;
|
|
61
|
-
disconnectJiraInstallation(input: {
|
|
61
|
+
disconnectJiraInstallation(input: {
|
|
62
|
+
connectionId: string;
|
|
63
|
+
lockAlreadyHeld?: boolean | undefined;
|
|
64
|
+
}): Promise<void>;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
export async function handleJiraCallback(
|
|
@@ -226,7 +229,7 @@ async function bestEffortDisconnect(
|
|
|
226
229
|
connectionId: string,
|
|
227
230
|
): Promise<void> {
|
|
228
231
|
try {
|
|
229
|
-
await params.disconnectJiraInstallation({connectionId});
|
|
232
|
+
await params.disconnectJiraInstallation({connectionId, lockAlreadyHeld: true});
|
|
230
233
|
} catch (error) {
|
|
231
234
|
logger().warn(
|
|
232
235
|
{err: error, connectionId},
|
|
@@ -33,6 +33,8 @@ function createStore() {
|
|
|
33
33
|
getAccessibleResources: vi.fn(),
|
|
34
34
|
getMyself: vi.fn(),
|
|
35
35
|
registerDynamicWebhook: vi.fn(),
|
|
36
|
+
refreshDynamicWebhooks: vi.fn(),
|
|
37
|
+
deleteDynamicWebhooks: vi.fn(),
|
|
36
38
|
deleteDynamicWebhook: vi.fn(),
|
|
37
39
|
};
|
|
38
40
|
const markConnectionError = vi.fn().mockResolvedValue(undefined);
|
|
@@ -304,6 +306,43 @@ describe('Jira token refresh', () => {
|
|
|
304
306
|
);
|
|
305
307
|
});
|
|
306
308
|
|
|
309
|
+
it('allows cleanup to use the stored token for an inactive connection', async () => {
|
|
310
|
+
const {connectionId, resolveConnection, store} = createStore();
|
|
311
|
+
await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
|
|
312
|
+
resolveConnection.mockResolvedValue({
|
|
313
|
+
workspaceId: crypto.randomUUID(),
|
|
314
|
+
lifecycleStatus: 'error',
|
|
315
|
+
});
|
|
316
|
+
getInstallation.mockResolvedValue({tokenExpiresAt: null});
|
|
317
|
+
|
|
318
|
+
await expect(store.getAccessToken({connectionId})).rejects.toBeInstanceOf(
|
|
319
|
+
JiraTokenUnrefreshableError,
|
|
320
|
+
);
|
|
321
|
+
await expect(store.getAccessToken({connectionId, allowInactive: true})).resolves.toBe(
|
|
322
|
+
'access-0',
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('refreshes an expired token for inactive cleanup', async () => {
|
|
327
|
+
const {client, connectionId, resolveConnection, store} = createStore();
|
|
328
|
+
await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
|
|
329
|
+
resolveConnection.mockResolvedValue({
|
|
330
|
+
workspaceId: crypto.randomUUID(),
|
|
331
|
+
lifecycleStatus: 'error',
|
|
332
|
+
});
|
|
333
|
+
client.refreshAccessToken.mockResolvedValue({
|
|
334
|
+
accessToken: 'access-1',
|
|
335
|
+
refreshToken: 'refresh-1',
|
|
336
|
+
expiresAt: new Date('2030-01-01'),
|
|
337
|
+
scopes: [],
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await expect(store.getAccessToken({connectionId, allowInactive: true})).resolves.toBe(
|
|
341
|
+
'access-1',
|
|
342
|
+
);
|
|
343
|
+
expect(client.refreshAccessToken).toHaveBeenCalledWith({refreshToken: 'refresh-0'});
|
|
344
|
+
});
|
|
345
|
+
|
|
307
346
|
it('requires a refresh token once the access token expires', async () => {
|
|
308
347
|
const {connectionId, store} = createStore();
|
|
309
348
|
await store.storeTokens({connectionId, accessToken: 'access-0'});
|
package/src/core/tokens.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface StoreJiraTokensParams {
|
|
|
50
50
|
export interface GetJiraAccessTokenParams {
|
|
51
51
|
connectionId: string;
|
|
52
52
|
forceRefresh?: boolean | undefined;
|
|
53
|
+
/** Allows cleanup to use the stored token after a connection leaves the active state. */
|
|
54
|
+
allowInactive?: boolean | undefined;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export interface JiraTokenStore {
|
|
@@ -139,11 +141,11 @@ export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTo
|
|
|
139
141
|
async getAccessToken(input) {
|
|
140
142
|
await waitForCredentialWrites(input.connectionId);
|
|
141
143
|
const connection = await resolveConnection(input.connectionId);
|
|
142
|
-
if (connection.lifecycleStatus !== 'active') {
|
|
144
|
+
if (!input.allowInactive && connection.lifecycleStatus !== 'active') {
|
|
143
145
|
throw new JiraTokenUnrefreshableError(input.connectionId);
|
|
144
146
|
}
|
|
145
147
|
await waitForCredentialWrites(input.connectionId);
|
|
146
|
-
if (refreshStateUnknownConnections.has(input.connectionId)) {
|
|
148
|
+
if (!input.allowInactive && refreshStateUnknownConnections.has(input.connectionId)) {
|
|
147
149
|
throw new JiraTokenUnrefreshableError(input.connectionId);
|
|
148
150
|
}
|
|
149
151
|
const credentialGeneration = currentCredentialGeneration(input.connectionId);
|
|
@@ -275,6 +275,38 @@ describe('registerJiraWebhook', () => {
|
|
|
275
275
|
expect(deleteDynamicWebhook).toHaveBeenCalledTimes(2);
|
|
276
276
|
});
|
|
277
277
|
|
|
278
|
+
it('retains a failed superseded webhook id when renewal replaces stale metadata', async () => {
|
|
279
|
+
const updateInstallation = vi
|
|
280
|
+
.fn()
|
|
281
|
+
.mockResolvedValueOnce({id: 'installation-1'})
|
|
282
|
+
.mockResolvedValueOnce({id: 'installation-1'});
|
|
283
|
+
const deleteDynamicWebhook = vi.fn().mockRejectedValue(new Error('webhook is gone'));
|
|
284
|
+
|
|
285
|
+
await registerJiraWebhook({
|
|
286
|
+
jira: {
|
|
287
|
+
registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 456}),
|
|
288
|
+
deleteDynamicWebhook,
|
|
289
|
+
},
|
|
290
|
+
connectionId: 'connection-1',
|
|
291
|
+
cloudId: 'cloud-1',
|
|
292
|
+
accessToken: 'access-token',
|
|
293
|
+
webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
294
|
+
getInstallation: vi.fn().mockResolvedValue({webhookIds: [123]}),
|
|
295
|
+
updateInstallation,
|
|
296
|
+
withRegistrationLock: runInlineRegistrationLock,
|
|
297
|
+
replaceExistingWebhooks: true,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(updateInstallation).toHaveBeenNthCalledWith(
|
|
301
|
+
1,
|
|
302
|
+
expect.objectContaining({webhookIds: [456]}),
|
|
303
|
+
);
|
|
304
|
+
expect(updateInstallation).toHaveBeenNthCalledWith(
|
|
305
|
+
2,
|
|
306
|
+
expect.objectContaining({webhookIds: [456, 123]}),
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
278
310
|
it('keeps conservative metadata when failed cleanup cannot be persisted', async () => {
|
|
279
311
|
const updateInstallation = vi
|
|
280
312
|
.fn()
|
|
@@ -19,6 +19,7 @@ export interface RegisterJiraWebhookParams {
|
|
|
19
19
|
getInstallation?: typeof getJiraInstallationByConnectionId;
|
|
20
20
|
updateInstallation?: typeof updateJiraInstallationWebhook;
|
|
21
21
|
withRegistrationLock?: JiraInstallationLock;
|
|
22
|
+
replaceExistingWebhooks?: boolean | undefined;
|
|
22
23
|
onRegistrationSuccess?: (input: {tx?: unknown}) => Promise<void>;
|
|
23
24
|
onRegistrationFailure?: (input: {tx?: unknown}) => Promise<void>;
|
|
24
25
|
}
|
|
@@ -51,7 +52,9 @@ export async function registerJiraWebhook(
|
|
|
51
52
|
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
52
53
|
const updateInput = {
|
|
53
54
|
connectionId: params.connectionId,
|
|
54
|
-
webhookIds:
|
|
55
|
+
webhookIds: params.replaceExistingWebhooks
|
|
56
|
+
? [registration.webhookId]
|
|
57
|
+
: [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],
|
|
55
58
|
webhookExpiresAt,
|
|
56
59
|
};
|
|
57
60
|
const installation = await updateInstallation(updateInput);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {config} from '#config.js';
|
|
2
|
+
|
|
3
|
+
export const JIRA_WEBHOOK_ROUTE_PREFIX = '/webhooks/integrations/jira';
|
|
4
|
+
|
|
5
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
6
|
+
|
|
7
|
+
export function jiraWebhookUrl(connectionId: string): string {
|
|
8
|
+
return `${config.JIRA_WEBHOOK_BASE_URL.replace(TRAILING_SLASHES_RE, '')}${JIRA_WEBHOOK_ROUTE_PREFIX}/${connectionId}`;
|
|
9
|
+
}
|