@shipfox/api-integration-core 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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +57 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/db/connections.d.ts.map +1 -1
- package/dist/db/connections.js +66 -13
- package/dist/db/connections.js.map +1 -1
- package/dist/db/db.d.ts +6 -6
- package/dist/db/schema/connections.d.ts +1 -1
- package/dist/db/schema/outbox.d.ts +1 -1
- package/dist/db/schema/webhook-deliveries.d.ts +1 -1
- package/dist/presentation/dto/integrations.d.ts +3 -3
- package/dist/presentation/dto/integrations.d.ts.map +1 -1
- package/dist/providers/jira.d.ts.map +1 -1
- package/dist/providers/jira.js +17 -1
- package/dist/providers/jira.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/db/connections.test.ts +90 -0
- package/src/db/connections.ts +92 -13
- package/src/providers/jira.test.ts +133 -0
- package/src/providers/jira.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-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -23,29 +23,29 @@
|
|
|
23
23
|
"ajv": "^8.20.0",
|
|
24
24
|
"drizzle-orm": "^0.45.2",
|
|
25
25
|
"zod": "^4.4.3",
|
|
26
|
-
"@shipfox/api-agent-dto": "12.
|
|
27
|
-
"@shipfox/api-auth-context": "12.
|
|
28
|
-
"@shipfox/api-workflows-dto": "12.
|
|
29
|
-
"@shipfox/api-integration-core-dto": "12.
|
|
30
|
-
"@shipfox/api-integration-spi": "1.0.
|
|
26
|
+
"@shipfox/api-agent-dto": "12.2.0",
|
|
27
|
+
"@shipfox/api-auth-context": "12.2.0",
|
|
28
|
+
"@shipfox/api-workflows-dto": "12.2.0",
|
|
29
|
+
"@shipfox/api-integration-core-dto": "12.2.0",
|
|
30
|
+
"@shipfox/api-integration-spi": "1.0.1",
|
|
31
31
|
"@shipfox/api-workspaces-dto": "12.0.0",
|
|
32
|
-
"@shipfox/api-integration-gitea": "12.
|
|
33
|
-
"@shipfox/api-integration-github": "12.
|
|
34
|
-
"@shipfox/api-integration-jira": "12.
|
|
35
|
-
"@shipfox/api-integration-linear": "12.
|
|
36
|
-
"@shipfox/api-integration-sentry": "12.
|
|
37
|
-
"@shipfox/api-integration-slack": "12.
|
|
38
|
-
"@shipfox/api-integration-webhook": "12.
|
|
32
|
+
"@shipfox/api-integration-gitea": "12.2.0",
|
|
33
|
+
"@shipfox/api-integration-github": "12.2.0",
|
|
34
|
+
"@shipfox/api-integration-jira": "12.2.0",
|
|
35
|
+
"@shipfox/api-integration-linear": "12.2.0",
|
|
36
|
+
"@shipfox/api-integration-sentry": "12.2.0",
|
|
37
|
+
"@shipfox/api-integration-slack": "12.2.0",
|
|
38
|
+
"@shipfox/api-integration-webhook": "12.2.0",
|
|
39
39
|
"@shipfox/config": "1.2.4",
|
|
40
40
|
"@shipfox/inter-module": "0.2.3",
|
|
41
41
|
"@shipfox/node-drizzle": "0.3.5",
|
|
42
|
-
"@shipfox/node-fastify": "0.4.
|
|
43
|
-
"@shipfox/node-module": "1.0.
|
|
44
|
-
"@shipfox/node-opentelemetry": "0.6.
|
|
42
|
+
"@shipfox/node-fastify": "0.4.2",
|
|
43
|
+
"@shipfox/node-module": "1.0.6",
|
|
44
|
+
"@shipfox/node-opentelemetry": "0.6.4",
|
|
45
45
|
"@shipfox/node-error-monitoring": "0.3.0",
|
|
46
46
|
"@shipfox/node-outbox": "0.2.6",
|
|
47
47
|
"@shipfox/node-postgres": "0.5.0",
|
|
48
|
-
"@shipfox/node-temporal": "0.4.
|
|
48
|
+
"@shipfox/node-temporal": "0.4.5",
|
|
49
49
|
"@shipfox/regex": "0.2.4",
|
|
50
50
|
"@shipfox/redact": "0.2.6"
|
|
51
51
|
},
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import {INTEGRATION_CONNECTION_AVAILABLE} from '@shipfox/api-integration-core-dto';
|
|
1
2
|
import {upsertGithubInstallation} from '@shipfox/api-integration-github';
|
|
2
3
|
import {ConnectionSlugConflictError} from '@shipfox/api-integration-spi';
|
|
4
|
+
import {sql} from 'drizzle-orm';
|
|
3
5
|
import {IntegrationConnectionAlreadyExistsError} from '#core/errors.js';
|
|
4
6
|
import {
|
|
5
7
|
createIntegrationConnection,
|
|
@@ -13,6 +15,16 @@ import {
|
|
|
13
15
|
upsertIntegrationConnection,
|
|
14
16
|
} from './connections.js';
|
|
15
17
|
import {db} from './db.js';
|
|
18
|
+
import {integrationsOutbox} from './schema/outbox.js';
|
|
19
|
+
|
|
20
|
+
function connectionEvents(connectionId: string) {
|
|
21
|
+
return db()
|
|
22
|
+
.select()
|
|
23
|
+
.from(integrationsOutbox)
|
|
24
|
+
.where(
|
|
25
|
+
sql`${integrationsOutbox.eventType} = ${INTEGRATION_CONNECTION_AVAILABLE} AND ${integrationsOutbox.payload}->>'connectionId' = ${connectionId}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
16
28
|
|
|
17
29
|
describe('integration connection queries', () => {
|
|
18
30
|
let workspaceId: string;
|
|
@@ -41,6 +53,31 @@ describe('integration connection queries', () => {
|
|
|
41
53
|
expect(second.id).toBe(first.id);
|
|
42
54
|
expect(second.displayName).toBe('Renamed Debug');
|
|
43
55
|
expect(second.slug).toBe('gitea_owner');
|
|
56
|
+
expect(await connectionEvents(first.id)).toHaveLength(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('publishes availability when an upsert activates an existing connection', async () => {
|
|
60
|
+
const connection = await upsertIntegrationConnection({
|
|
61
|
+
workspaceId,
|
|
62
|
+
provider: 'linear',
|
|
63
|
+
externalAccountId: 'linear-acme',
|
|
64
|
+
slug: 'linear_acme',
|
|
65
|
+
displayName: 'Linear Acme',
|
|
66
|
+
lifecycleStatus: 'disabled',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(await connectionEvents(connection.id)).toHaveLength(0);
|
|
70
|
+
|
|
71
|
+
await upsertIntegrationConnection({
|
|
72
|
+
workspaceId,
|
|
73
|
+
provider: 'linear',
|
|
74
|
+
externalAccountId: 'linear-acme',
|
|
75
|
+
slug: 'linear_acme',
|
|
76
|
+
displayName: 'Linear Acme',
|
|
77
|
+
lifecycleStatus: 'active',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(await connectionEvents(connection.id)).toHaveLength(1);
|
|
44
81
|
});
|
|
45
82
|
|
|
46
83
|
it('allows multiple same-provider connections when external account differs', async () => {
|
|
@@ -148,6 +185,7 @@ describe('integration connection queries', () => {
|
|
|
148
185
|
expect(connections).toHaveLength(1);
|
|
149
186
|
expect(connections[0]?.id).toBe(first.id);
|
|
150
187
|
expect(connections[0]?.displayName).toBe('Stripe');
|
|
188
|
+
expect(await connectionEvents(first.id)).toHaveLength(1);
|
|
151
189
|
});
|
|
152
190
|
|
|
153
191
|
it('reports slug collisions separately from duplicate external accounts', async () => {
|
|
@@ -252,6 +290,51 @@ describe('integration connection queries', () => {
|
|
|
252
290
|
expect(updated?.lifecycleStatus).toBe('disabled');
|
|
253
291
|
const reloaded = await getIntegrationConnectionById(connection.id);
|
|
254
292
|
expect(reloaded?.lifecycleStatus).toBe('disabled');
|
|
293
|
+
expect(await connectionEvents(connection.id)).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('publishes availability when a disabled connection becomes active', async () => {
|
|
297
|
+
const connection = await upsertIntegrationConnection({
|
|
298
|
+
workspaceId,
|
|
299
|
+
provider: 'linear',
|
|
300
|
+
externalAccountId: 'linear-acme',
|
|
301
|
+
slug: 'linear_acme',
|
|
302
|
+
displayName: 'Linear Acme',
|
|
303
|
+
lifecycleStatus: 'disabled',
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(await connectionEvents(connection.id)).toHaveLength(0);
|
|
307
|
+
|
|
308
|
+
await updateIntegrationConnectionLifecycleStatus({
|
|
309
|
+
id: connection.id,
|
|
310
|
+
lifecycleStatus: 'active',
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const events = await connectionEvents(connection.id);
|
|
314
|
+
expect(events).toHaveLength(1);
|
|
315
|
+
expect(events[0]?.payload).toEqual({
|
|
316
|
+
provider: 'linear',
|
|
317
|
+
workspaceId,
|
|
318
|
+
connectionId: connection.id,
|
|
319
|
+
slug: 'linear_acme',
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('does not republish availability when an active connection stays active', async () => {
|
|
324
|
+
const connection = await upsertIntegrationConnection({
|
|
325
|
+
workspaceId,
|
|
326
|
+
provider: 'linear',
|
|
327
|
+
externalAccountId: 'linear-acme',
|
|
328
|
+
slug: 'linear_acme',
|
|
329
|
+
displayName: 'Linear Acme',
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
await updateIntegrationConnectionLifecycleStatus({
|
|
333
|
+
id: connection.id,
|
|
334
|
+
lifecycleStatus: 'active',
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(await connectionEvents(connection.id)).toHaveLength(1);
|
|
255
338
|
});
|
|
256
339
|
|
|
257
340
|
it('returns undefined when updating the lifecycle status of an unknown connection', async () => {
|
|
@@ -310,5 +393,12 @@ describe('integration connection queries', () => {
|
|
|
310
393
|
|
|
311
394
|
const connections = await listIntegrationConnections({workspaceId});
|
|
312
395
|
expect(connections).toHaveLength(0);
|
|
396
|
+
const events = await db()
|
|
397
|
+
.select()
|
|
398
|
+
.from(integrationsOutbox)
|
|
399
|
+
.where(
|
|
400
|
+
sql`${integrationsOutbox.eventType} = ${INTEGRATION_CONNECTION_AVAILABLE} AND ${integrationsOutbox.payload}->>'workspaceId' = ${workspaceId}`,
|
|
401
|
+
);
|
|
402
|
+
expect(events).toHaveLength(0);
|
|
313
403
|
});
|
|
314
404
|
});
|
package/src/db/connections.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CONNECTION_SLUG_MAX_LENGTH,
|
|
3
|
+
INTEGRATION_CONNECTION_AVAILABLE,
|
|
4
|
+
type IntegrationsEventMap,
|
|
5
|
+
} from '@shipfox/api-integration-core-dto';
|
|
2
6
|
import {ConnectionSlugConflictError} from '@shipfox/api-integration-spi';
|
|
7
|
+
import {writeOutboxEvent} from '@shipfox/node-outbox';
|
|
3
8
|
import {and, eq} from 'drizzle-orm';
|
|
4
9
|
import type {
|
|
5
10
|
IntegrationConnection,
|
|
@@ -9,6 +14,7 @@ import type {IntegrationProviderKind} from '#core/entities/provider.js';
|
|
|
9
14
|
import {IntegrationConnectionAlreadyExistsError} from '#core/errors.js';
|
|
10
15
|
import {db} from './db.js';
|
|
11
16
|
import {integrationConnections, toIntegrationConnection} from './schema/connections.js';
|
|
17
|
+
import {integrationsOutbox} from './schema/outbox.js';
|
|
12
18
|
|
|
13
19
|
type IntegrationDb = ReturnType<typeof db>;
|
|
14
20
|
type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
|
|
@@ -26,9 +32,13 @@ export async function upsertIntegrationConnection(
|
|
|
26
32
|
params: UpsertIntegrationConnectionParams,
|
|
27
33
|
options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
|
|
28
34
|
): Promise<IntegrationConnection> {
|
|
29
|
-
|
|
35
|
+
if (options.tx === undefined) {
|
|
36
|
+
return await db().transaction((tx) => upsertIntegrationConnection(params, {tx}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const executor = options.tx;
|
|
30
40
|
const now = new Date();
|
|
31
|
-
|
|
41
|
+
let [row] = await executor
|
|
32
42
|
.insert(integrationConnections)
|
|
33
43
|
.values({
|
|
34
44
|
workspaceId: params.workspaceId,
|
|
@@ -38,22 +48,52 @@ export async function upsertIntegrationConnection(
|
|
|
38
48
|
displayName: params.displayName,
|
|
39
49
|
lifecycleStatus: params.lifecycleStatus ?? 'active',
|
|
40
50
|
})
|
|
41
|
-
.
|
|
51
|
+
.onConflictDoNothing({
|
|
42
52
|
target: [
|
|
43
53
|
integrationConnections.workspaceId,
|
|
44
54
|
integrationConnections.provider,
|
|
45
55
|
integrationConnections.externalAccountId,
|
|
46
56
|
],
|
|
47
|
-
|
|
57
|
+
})
|
|
58
|
+
.returning();
|
|
59
|
+
|
|
60
|
+
let becameAvailable = row?.lifecycleStatus === 'active';
|
|
61
|
+
if (!row) {
|
|
62
|
+
const [existing] = await executor
|
|
63
|
+
.select({
|
|
64
|
+
id: integrationConnections.id,
|
|
65
|
+
lifecycleStatus: integrationConnections.lifecycleStatus,
|
|
66
|
+
})
|
|
67
|
+
.from(integrationConnections)
|
|
68
|
+
.where(
|
|
69
|
+
and(
|
|
70
|
+
eq(integrationConnections.workspaceId, params.workspaceId),
|
|
71
|
+
eq(integrationConnections.provider, params.provider),
|
|
72
|
+
eq(integrationConnections.externalAccountId, params.externalAccountId),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
.limit(1)
|
|
76
|
+
.for('update');
|
|
77
|
+
if (!existing) throw new Error('Integration connection upsert conflict row was not found');
|
|
78
|
+
|
|
79
|
+
[row] = await executor
|
|
80
|
+
.update(integrationConnections)
|
|
81
|
+
.set({
|
|
48
82
|
displayName: params.displayName,
|
|
49
83
|
lifecycleStatus: params.lifecycleStatus ?? 'active',
|
|
50
84
|
updatedAt: now,
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
})
|
|
86
|
+
.where(eq(integrationConnections.id, existing.id))
|
|
87
|
+
.returning();
|
|
88
|
+
becameAvailable = existing.lifecycleStatus !== 'active' && row?.lifecycleStatus === 'active';
|
|
89
|
+
}
|
|
54
90
|
|
|
55
91
|
if (!row) throw new Error('Integration connection upsert returned no rows');
|
|
56
|
-
|
|
92
|
+
const connection = toIntegrationConnection(row);
|
|
93
|
+
if (becameAvailable) {
|
|
94
|
+
await writeConnectionAvailableEvent(executor, connection);
|
|
95
|
+
}
|
|
96
|
+
return connection;
|
|
57
97
|
}
|
|
58
98
|
|
|
59
99
|
export interface CreateIntegrationConnectionParams {
|
|
@@ -100,7 +140,11 @@ export async function createIntegrationConnection(
|
|
|
100
140
|
params: CreateIntegrationConnectionParams,
|
|
101
141
|
options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
|
|
102
142
|
): Promise<IntegrationConnection> {
|
|
103
|
-
|
|
143
|
+
if (options.tx === undefined) {
|
|
144
|
+
return await db().transaction((tx) => createIntegrationConnection(params, {tx}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const executor = options.tx;
|
|
104
148
|
let rows: (typeof integrationConnections.$inferSelect)[];
|
|
105
149
|
try {
|
|
106
150
|
rows = await executor
|
|
@@ -130,7 +174,11 @@ export async function createIntegrationConnection(
|
|
|
130
174
|
|
|
131
175
|
const row = rows[0];
|
|
132
176
|
if (!row) throw new Error('Integration connection insert returned no rows');
|
|
133
|
-
|
|
177
|
+
const connection = toIntegrationConnection(row);
|
|
178
|
+
if (connection.lifecycleStatus === 'active') {
|
|
179
|
+
await writeConnectionAvailableEvent(executor, connection);
|
|
180
|
+
}
|
|
181
|
+
return connection;
|
|
134
182
|
}
|
|
135
183
|
|
|
136
184
|
export interface ResolveUniqueConnectionSlugParams {
|
|
@@ -225,19 +273,50 @@ export async function updateIntegrationConnectionLifecycleStatus(
|
|
|
225
273
|
params: UpdateIntegrationConnectionLifecycleStatusParams,
|
|
226
274
|
options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
|
|
227
275
|
): Promise<IntegrationConnection | undefined> {
|
|
228
|
-
|
|
276
|
+
if (options.tx === undefined) {
|
|
277
|
+
return await db().transaction((tx) => updateIntegrationConnectionLifecycleStatus(params, {tx}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const executor = options.tx;
|
|
281
|
+
const [existing] = await executor
|
|
282
|
+
.select({lifecycleStatus: integrationConnections.lifecycleStatus})
|
|
283
|
+
.from(integrationConnections)
|
|
284
|
+
.where(eq(integrationConnections.id, params.id))
|
|
285
|
+
.limit(1)
|
|
286
|
+
.for('update');
|
|
287
|
+
if (!existing) return undefined;
|
|
288
|
+
|
|
229
289
|
const [row] = await executor
|
|
230
290
|
.update(integrationConnections)
|
|
231
291
|
.set({lifecycleStatus: params.lifecycleStatus, updatedAt: new Date()})
|
|
232
292
|
.where(eq(integrationConnections.id, params.id))
|
|
233
293
|
.returning();
|
|
234
294
|
if (!row) return undefined;
|
|
235
|
-
|
|
295
|
+
const connection = toIntegrationConnection(row);
|
|
296
|
+
if (existing.lifecycleStatus !== 'active' && connection.lifecycleStatus === 'active') {
|
|
297
|
+
await writeConnectionAvailableEvent(executor, connection);
|
|
298
|
+
}
|
|
299
|
+
return connection;
|
|
236
300
|
}
|
|
237
301
|
|
|
238
302
|
export type UpdateIntegrationConnectionLifecycleStatusFn =
|
|
239
303
|
typeof updateIntegrationConnectionLifecycleStatus;
|
|
240
304
|
|
|
305
|
+
async function writeConnectionAvailableEvent(
|
|
306
|
+
executor: IntegrationDb | IntegrationTx,
|
|
307
|
+
connection: IntegrationConnection,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
await writeOutboxEvent<IntegrationsEventMap>(executor, integrationsOutbox, {
|
|
310
|
+
type: INTEGRATION_CONNECTION_AVAILABLE,
|
|
311
|
+
payload: {
|
|
312
|
+
provider: connection.provider,
|
|
313
|
+
workspaceId: connection.workspaceId,
|
|
314
|
+
connectionId: connection.id,
|
|
315
|
+
slug: connection.slug,
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
241
320
|
export async function deleteIntegrationConnection(
|
|
242
321
|
params: {id: string},
|
|
243
322
|
options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
|
|
@@ -1,16 +1,65 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getJiraInstallationByConnectionId,
|
|
3
3
|
upsertJiraInstallation,
|
|
4
|
+
withJiraRefreshLock,
|
|
4
5
|
} from '@shipfox/api-integration-jira';
|
|
5
6
|
import {runMigrations} from '@shipfox/node-drizzle';
|
|
6
7
|
import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
|
|
8
|
+
import {db} from '#db/db.js';
|
|
9
|
+
import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
|
|
7
10
|
|
|
8
11
|
describe('jiraProviderModule', () => {
|
|
12
|
+
const context = useIntegrationRouteTest();
|
|
13
|
+
|
|
9
14
|
afterEach(() => {
|
|
10
15
|
vi.unstubAllEnvs();
|
|
11
16
|
vi.resetModules();
|
|
12
17
|
});
|
|
13
18
|
|
|
19
|
+
async function createJiraCleanupFixture() {
|
|
20
|
+
vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
const deleteSecrets = vi.fn(() => Promise.resolve(2));
|
|
23
|
+
const scopedSecrets = {
|
|
24
|
+
getSecret: vi.fn(() => Promise.resolve(null)),
|
|
25
|
+
setSecrets: vi.fn(() => Promise.resolve()),
|
|
26
|
+
deleteSecrets,
|
|
27
|
+
};
|
|
28
|
+
const {createPostgresClient} = await import('@shipfox/node-postgres');
|
|
29
|
+
createPostgresClient();
|
|
30
|
+
const {loadEnabledProviderModules} = await import('#providers/modules.js');
|
|
31
|
+
const parts = await loadEnabledProviderModules({
|
|
32
|
+
secrets: {jira: scopedSecrets, deleteSecrets},
|
|
33
|
+
});
|
|
34
|
+
const jiraPart = parts.find((part) => part.provider.provider === 'jira');
|
|
35
|
+
if (!jiraPart?.database) throw new Error('Jira provider database is not configured');
|
|
36
|
+
const cloudId = crypto.randomUUID();
|
|
37
|
+
|
|
38
|
+
await runMigrations(
|
|
39
|
+
jiraPart.database.db(),
|
|
40
|
+
jiraPart.database.migrationsPath,
|
|
41
|
+
`__drizzle_migrations_${jiraPart.database.databaseNamespace}`,
|
|
42
|
+
);
|
|
43
|
+
const connection = await upsertIntegrationConnection({
|
|
44
|
+
workspaceId: context.workspaceId,
|
|
45
|
+
provider: 'jira',
|
|
46
|
+
externalAccountId: cloudId,
|
|
47
|
+
slug: 'jira_acme',
|
|
48
|
+
displayName: 'Jira Acme',
|
|
49
|
+
});
|
|
50
|
+
await upsertJiraInstallation({
|
|
51
|
+
connectionId: connection.id,
|
|
52
|
+
cloudId,
|
|
53
|
+
siteUrl: 'https://acme.atlassian.net',
|
|
54
|
+
siteName: 'Acme',
|
|
55
|
+
authorizingAccountId: 'user-1',
|
|
56
|
+
scopes: ['read:jira-work'],
|
|
57
|
+
status: 'installed',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {connection, deleteSecrets, jiraPart, cloudId};
|
|
61
|
+
}
|
|
62
|
+
|
|
14
63
|
it('loads its database descriptor and persists a core connection with its Jira installation', async () => {
|
|
15
64
|
vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
|
|
16
65
|
vi.resetModules();
|
|
@@ -59,4 +108,88 @@ describe('jiraProviderModule', () => {
|
|
|
59
108
|
cloudId,
|
|
60
109
|
});
|
|
61
110
|
});
|
|
111
|
+
|
|
112
|
+
it('waits for refresh before deleting tokens and allowing a reinstall', async () => {
|
|
113
|
+
const {cloudId, connection, deleteSecrets, jiraPart} = await createJiraCleanupFixture();
|
|
114
|
+
const app = await createTestApp([jiraPart.provider]);
|
|
115
|
+
|
|
116
|
+
let releaseRefreshLock!: () => void;
|
|
117
|
+
let refreshLockEntered!: () => void;
|
|
118
|
+
const refreshLockReady = new Promise<void>((resolve) => {
|
|
119
|
+
refreshLockEntered = resolve;
|
|
120
|
+
});
|
|
121
|
+
const refreshLockReleased = new Promise<void>((resolve) => {
|
|
122
|
+
releaseRefreshLock = resolve;
|
|
123
|
+
});
|
|
124
|
+
const refreshLock = withJiraRefreshLock(connection.id, async () => {
|
|
125
|
+
refreshLockEntered();
|
|
126
|
+
await refreshLockReleased;
|
|
127
|
+
});
|
|
128
|
+
await refreshLockReady;
|
|
129
|
+
|
|
130
|
+
const deletion = app.inject({
|
|
131
|
+
method: 'DELETE',
|
|
132
|
+
url: `/integration-connections/${connection.id}`,
|
|
133
|
+
headers: {authorization: 'Bearer user'},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await vi.waitFor(async () => {
|
|
137
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
let res!: Awaited<ReturnType<typeof app.inject>>;
|
|
140
|
+
try {
|
|
141
|
+
expect(deleteSecrets).not.toHaveBeenCalled();
|
|
142
|
+
} finally {
|
|
143
|
+
releaseRefreshLock();
|
|
144
|
+
await expect(refreshLock).resolves.toMatchObject({acquired: true});
|
|
145
|
+
res = await deletion;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
expect(res.statusCode).toBe(204);
|
|
149
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
150
|
+
await expect(getJiraInstallationByConnectionId(connection.id)).resolves.toBeUndefined();
|
|
151
|
+
expect(deleteSecrets).toHaveBeenCalledWith({
|
|
152
|
+
workspaceId: context.workspaceId,
|
|
153
|
+
namespace: connection.id,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const replacement = await upsertIntegrationConnection({
|
|
157
|
+
workspaceId: context.workspaceId,
|
|
158
|
+
provider: 'jira',
|
|
159
|
+
externalAccountId: cloudId,
|
|
160
|
+
slug: 'jira_acme_again',
|
|
161
|
+
displayName: 'Jira Acme',
|
|
162
|
+
});
|
|
163
|
+
await upsertJiraInstallation({
|
|
164
|
+
connectionId: replacement.id,
|
|
165
|
+
cloudId,
|
|
166
|
+
siteUrl: 'https://acme.atlassian.net',
|
|
167
|
+
siteName: 'Acme',
|
|
168
|
+
authorizingAccountId: 'user-1',
|
|
169
|
+
scopes: ['read:jira-work'],
|
|
170
|
+
status: 'installed',
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(getJiraInstallationByConnectionId(replacement.id)).resolves.toMatchObject({
|
|
174
|
+
cloudId,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rolls back provider record cleanup when its transaction fails', async () => {
|
|
179
|
+
const {connection, jiraPart} = await createJiraCleanupFixture();
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
db().transaction(async (tx) => {
|
|
183
|
+
await jiraPart.provider.deleteConnectionRecords?.(connection, {tx});
|
|
184
|
+
throw new Error('transaction failed');
|
|
185
|
+
}),
|
|
186
|
+
).rejects.toThrow('transaction failed');
|
|
187
|
+
|
|
188
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
|
|
189
|
+
id: connection.id,
|
|
190
|
+
});
|
|
191
|
+
await expect(getJiraInstallationByConnectionId(connection.id)).resolves.toMatchObject({
|
|
192
|
+
connectionId: connection.id,
|
|
193
|
+
});
|
|
194
|
+
});
|
|
62
195
|
});
|
package/src/providers/jira.ts
CHANGED
|
@@ -30,10 +30,13 @@ async function loadJiraModuleParts(
|
|
|
30
30
|
createJiraPendingSelectionStore,
|
|
31
31
|
createJiraTokenStore,
|
|
32
32
|
db: jiraDb,
|
|
33
|
+
deleteJiraInstallationByConnectionId,
|
|
33
34
|
disconnectJiraInstallation: disconnectJiraInstallationRecords,
|
|
34
35
|
getJiraInstallationByCloudId,
|
|
36
|
+
jiraSecretsNamespace,
|
|
35
37
|
migrationsPath,
|
|
36
38
|
upsertJiraInstallation,
|
|
39
|
+
withJiraRefreshLockAndWait,
|
|
37
40
|
} = await import('@shipfox/api-integration-jira');
|
|
38
41
|
|
|
39
42
|
async function getExistingJiraConnection(input: {
|
|
@@ -144,6 +147,20 @@ async function loadJiraModuleParts(
|
|
|
144
147
|
|
|
145
148
|
const integrationProvider = createJiraIntegrationProvider({
|
|
146
149
|
agentTools: {tokenStore},
|
|
150
|
+
cleanup: {
|
|
151
|
+
deleteConnectionRecords: async (connection, {tx}) => {
|
|
152
|
+
await deleteJiraInstallationByConnectionId(connection.id, {tx});
|
|
153
|
+
},
|
|
154
|
+
deleteConnectionSecrets: async (connection) => {
|
|
155
|
+
await withJiraRefreshLockAndWait(connection.id, async () => {
|
|
156
|
+
// Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
|
|
157
|
+
await (options.secrets?.jira?.deleteSecrets({
|
|
158
|
+
workspaceId: connection.workspaceId,
|
|
159
|
+
namespace: jiraNamespaceSuffix(jiraSecretsNamespace(connection.id)),
|
|
160
|
+
}) ?? Promise.resolve());
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
},
|
|
147
164
|
routes: {
|
|
148
165
|
tokenStore,
|
|
149
166
|
pendingStore,
|