@shipfox/api-integration-jira 12.1.0 → 12.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-jira",
3
3
  "license": "MIT",
4
- "version": "12.1.0",
4
+ "version": "12.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,15 +27,15 @@
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "ky": "^2.0.0",
29
29
  "zod": "^4.4.3",
30
- "@shipfox/api-auth-context": "12.0.0",
31
- "@shipfox/api-integration-spi": "1.0.0",
32
- "@shipfox/api-integration-jira-dto": "12.0.0",
30
+ "@shipfox/api-auth-context": "12.2.0",
31
+ "@shipfox/api-integration-spi": "1.0.1",
32
+ "@shipfox/api-integration-jira-dto": "12.2.0",
33
33
  "@shipfox/config": "1.2.4",
34
34
  "@shipfox/node-drizzle": "0.3.5",
35
- "@shipfox/node-fastify": "0.4.1",
35
+ "@shipfox/node-fastify": "0.4.2",
36
36
  "@shipfox/node-jwt": "0.4.0",
37
- "@shipfox/node-module": "1.0.5",
38
- "@shipfox/node-opentelemetry": "0.6.3",
37
+ "@shipfox/node-module": "1.0.6",
38
+ "@shipfox/node-opentelemetry": "0.6.4",
39
39
  "@shipfox/node-postgres": "0.5.0"
40
40
  },
41
41
  "imports": {
@@ -2,7 +2,18 @@ import {HTTPError} from 'ky';
2
2
  import type {JiraIntegrationProviderError} from '#core/errors.js';
3
3
  import {mapJiraError} from './client.js';
4
4
 
5
- const mocks = vi.hoisted(() => ({delete: vi.fn(), post: vi.fn(), request: vi.fn()}));
5
+ const mocks = vi.hoisted(() => ({
6
+ delete: vi.fn(),
7
+ post: vi.fn(),
8
+ request: vi.fn(),
9
+ warn: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('@shipfox/node-opentelemetry', () => ({
13
+ logger: () => ({
14
+ warn: mocks.warn,
15
+ }),
16
+ }));
6
17
 
7
18
  vi.mock('ky', () => {
8
19
  class MockHTTPError extends Error {
@@ -77,6 +88,7 @@ describe('Jira dynamic webhook API', () => {
77
88
  mocks.delete.mockReset();
78
89
  mocks.post.mockReset();
79
90
  mocks.request.mockReset();
91
+ mocks.warn.mockReset();
80
92
  });
81
93
 
82
94
  it('registers the six curated events with the access token', async () => {
@@ -133,6 +145,165 @@ describe('Jira dynamic webhook API', () => {
133
145
  ).rejects.toMatchObject({reason: 'malformed-provider-response'});
134
146
  });
135
147
 
148
+ it('logs provider validation errors before rejecting registration', async () => {
149
+ mocks.post.mockReturnValue(
150
+ resolves({
151
+ webhookRegistrationResult: [
152
+ {createdWebhookId: 123, errors: ['Webhook URL is not approved', 'Invalid JQL filter']},
153
+ ],
154
+ }),
155
+ );
156
+ const {createJiraApiClient} = await import('./client.js');
157
+
158
+ await expect(
159
+ createJiraApiClient().registerDynamicWebhook({
160
+ accessToken: 'access-token',
161
+ cloudId: 'cloud-1',
162
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
163
+ }),
164
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
165
+
166
+ expect(mocks.warn).toHaveBeenCalledTimes(1);
167
+ expect(mocks.warn).toHaveBeenCalledWith(
168
+ {
169
+ operation: 'register-dynamic-webhook',
170
+ providerErrors: ['Webhook URL is not approved', 'Invalid JQL filter'],
171
+ providerErrorCount: 2,
172
+ },
173
+ 'Jira dynamic webhook registration rejected',
174
+ );
175
+ });
176
+
177
+ it('limits logged provider errors to five values', async () => {
178
+ mocks.post.mockReturnValue(
179
+ resolves({
180
+ webhookRegistrationResult: [
181
+ {
182
+ errors: ['one', 'two', 'three', 'four', 'five', 'six'],
183
+ },
184
+ ],
185
+ }),
186
+ );
187
+ const {createJiraApiClient} = await import('./client.js');
188
+
189
+ await expect(
190
+ createJiraApiClient().registerDynamicWebhook({
191
+ accessToken: 'access-token',
192
+ cloudId: 'cloud-1',
193
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
194
+ }),
195
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
196
+
197
+ expect(mocks.warn).toHaveBeenCalledWith(
198
+ {
199
+ operation: 'register-dynamic-webhook',
200
+ providerErrors: ['one', 'two', 'three', 'four', 'five'],
201
+ providerErrorCount: 6,
202
+ },
203
+ 'Jira dynamic webhook registration rejected',
204
+ );
205
+ });
206
+
207
+ it('limits each logged provider error to 500 characters', async () => {
208
+ const longError = 'x'.repeat(501);
209
+ mocks.post.mockReturnValue(resolves({webhookRegistrationResult: [{errors: [longError]}]}));
210
+ const {createJiraApiClient} = await import('./client.js');
211
+
212
+ await expect(
213
+ createJiraApiClient().registerDynamicWebhook({
214
+ accessToken: 'access-token',
215
+ cloudId: 'cloud-1',
216
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
217
+ }),
218
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
219
+
220
+ expect(mocks.warn).toHaveBeenCalledWith(
221
+ {
222
+ operation: 'register-dynamic-webhook',
223
+ providerErrors: ['x'.repeat(500)],
224
+ providerErrorCount: 1,
225
+ },
226
+ 'Jira dynamic webhook registration rejected',
227
+ );
228
+ });
229
+
230
+ it('omits non-string provider errors from the warning', async () => {
231
+ mocks.post.mockReturnValue(
232
+ resolves({
233
+ webhookRegistrationResult: [
234
+ {errors: ['valid message', 42, null, {message: 'raw response'}, true]},
235
+ ],
236
+ }),
237
+ );
238
+ const {createJiraApiClient} = await import('./client.js');
239
+
240
+ await expect(
241
+ createJiraApiClient().registerDynamicWebhook({
242
+ accessToken: 'access-token',
243
+ cloudId: 'cloud-1',
244
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
245
+ }),
246
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
247
+
248
+ expect(mocks.warn).toHaveBeenCalledWith(
249
+ {
250
+ operation: 'register-dynamic-webhook',
251
+ providerErrors: ['valid message'],
252
+ providerErrorCount: 5,
253
+ },
254
+ 'Jira dynamic webhook registration rejected',
255
+ );
256
+ });
257
+
258
+ it('accepts an empty provider errors array without logging a rejection', async () => {
259
+ mocks.post.mockReturnValue(
260
+ resolves({webhookRegistrationResult: [{createdWebhookId: 123, errors: []}]}),
261
+ );
262
+ const {createJiraApiClient} = await import('./client.js');
263
+
264
+ await expect(
265
+ createJiraApiClient().registerDynamicWebhook({
266
+ accessToken: 'access-token',
267
+ cloudId: 'cloud-1',
268
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
269
+ }),
270
+ ).resolves.toEqual({webhookId: 123});
271
+ expect(mocks.warn).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it('rejects a non-array provider errors value without logging it', async () => {
275
+ mocks.post.mockReturnValue(
276
+ resolves({
277
+ webhookRegistrationResult: [{createdWebhookId: 123, errors: {message: 'do not log me'}}],
278
+ }),
279
+ );
280
+ const {createJiraApiClient} = await import('./client.js');
281
+
282
+ await expect(
283
+ createJiraApiClient().registerDynamicWebhook({
284
+ accessToken: 'access-token',
285
+ cloudId: 'cloud-1',
286
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
287
+ }),
288
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
289
+ expect(mocks.warn).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it('keeps the malformed provider response reason for a Jira rejection', async () => {
293
+ mocks.post.mockReturnValue(
294
+ resolves({webhookRegistrationResult: [{errors: ['Provider rejected the webhook']}]}),
295
+ );
296
+ const {createJiraApiClient} = await import('./client.js');
297
+
298
+ await expect(
299
+ createJiraApiClient().registerDynamicWebhook({
300
+ accessToken: 'access-token',
301
+ cloudId: 'cloud-1',
302
+ url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
303
+ }),
304
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
305
+ });
306
+
136
307
  it('deletes a dynamic webhook by id', async () => {
137
308
  mocks.delete.mockResolvedValue(undefined);
138
309
  const {createJiraApiClient} = await import('./client.js');
package/src/api/client.ts CHANGED
@@ -282,7 +282,21 @@ function parseDynamicWebhookRegistration(
282
282
  createdWebhookId?: unknown;
283
283
  errors?: unknown;
284
284
  };
285
- if (errors !== undefined && (!Array.isArray(errors) || errors.length > 0)) {
285
+ if (errors !== undefined && !Array.isArray(errors)) {
286
+ throw malformed('Jira webhook registration returned errors');
287
+ }
288
+ if (Array.isArray(errors) && errors.length > 0) {
289
+ logger().warn(
290
+ {
291
+ operation: 'register-dynamic-webhook',
292
+ providerErrors: errors
293
+ .filter((error): error is string => typeof error === 'string')
294
+ .slice(0, 5)
295
+ .map((error) => error.slice(0, 500)),
296
+ providerErrorCount: errors.length,
297
+ },
298
+ 'Jira dynamic webhook registration rejected',
299
+ );
286
300
  throw malformed('Jira webhook registration returned errors');
287
301
  }
288
302
  if (
package/src/index.test.ts CHANGED
@@ -31,6 +31,17 @@ describe('createJiraIntegrationProvider', () => {
31
31
  }),
32
32
  ).toThrow('requires all webhook receiver dependencies');
33
33
  });
34
+
35
+ it('exposes explicit connection cleanup without requiring routes', () => {
36
+ const deleteConnectionRecords = vi.fn(() => Promise.resolve());
37
+ const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
38
+ const provider = createJiraIntegrationProvider({
39
+ cleanup: {deleteConnectionRecords, deleteConnectionSecrets},
40
+ });
41
+
42
+ expect(provider.deleteConnectionRecords).toBe(deleteConnectionRecords);
43
+ expect(provider.deleteConnectionSecrets).toBe(deleteConnectionSecrets);
44
+ });
34
45
  });
35
46
 
36
47
  describe('createJiraMaintenanceWorker', () => {
package/src/index.ts CHANGED
@@ -126,6 +126,7 @@ export {
126
126
  updateJiraInstallationWebhook,
127
127
  upsertJiraInstallation,
128
128
  withJiraRefreshLock,
129
+ withJiraRefreshLockAndWait,
129
130
  withJiraWebhookRegistrationLock,
130
131
  } from '#db/installations.js';
131
132
  export type {CreateJiraWebhookRoutesOptions} from '#presentation/routes/webhooks.js';
@@ -142,6 +143,15 @@ export interface CreateJiraIntegrationProviderOptions {
142
143
  }
143
144
  | undefined;
144
145
  getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId | undefined;
146
+ cleanup?:
147
+ | {
148
+ deleteConnectionRecords?: (
149
+ connection: {id: string},
150
+ options: {tx: unknown},
151
+ ) => Promise<void>;
152
+ deleteConnectionSecrets?: (connection: {id: string; workspaceId: string}) => Promise<void>;
153
+ }
154
+ | undefined;
145
155
  routes?: JiraIntegrationProviderRoutesOptions | undefined;
146
156
  }
147
157
 
@@ -210,6 +220,7 @@ export function createJiraIntegrationProvider(options: CreateJiraIntegrationProv
210
220
  async connectionExternalUrl(connection: {id: string}): Promise<string | undefined> {
211
221
  return (await getInstallationByConnectionId(connection.id))?.siteUrl;
212
222
  },
223
+ ...options.cleanup,
213
224
  routes,
214
225
  webhookProcessors: webhookProcessor
215
226
  ? [{routeIds: ['jira'] as const, processor: webhookProcessor}]