@shipfox/api-integration-core 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 +11 -8
- package/CHANGELOG.md +25 -0
- package/dist/core/secret-cleanup.d.ts +20 -0
- package/dist/core/secret-cleanup.d.ts.map +1 -0
- package/dist/core/secret-cleanup.js +178 -0
- package/dist/core/secret-cleanup.js.map +1 -0
- package/dist/db/db.d.ts +562 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +2 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/secret-cleanups.d.ts +284 -0
- package/dist/db/schema/secret-cleanups.d.ts.map +1 -0
- package/dist/db/schema/secret-cleanups.js +39 -0
- package/dist/db/schema/secret-cleanups.js.map +1 -0
- package/dist/db/secret-cleanups.d.ts +52 -0
- package/dist/db/secret-cleanups.d.ts.map +1 -0
- package/dist/db/secret-cleanups.js +105 -0
- package/dist/db/secret-cleanups.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
- package/dist/presentation/routes/manage-connections.js +57 -18
- package/dist/presentation/routes/manage-connections.js.map +1 -1
- package/dist/providers/jira.d.ts.map +1 -1
- package/dist/providers/jira.js +51 -14
- package/dist/providers/jira.js.map +1 -1
- package/dist/temporal/activities/index.d.ts +5 -1
- package/dist/temporal/activities/index.d.ts.map +1 -1
- package/dist/temporal/activities/index.js +8 -2
- package/dist/temporal/activities/index.js.map +1 -1
- package/dist/temporal/constants.d.ts +1 -0
- package/dist/temporal/constants.d.ts.map +1 -1
- package/dist/temporal/constants.js +1 -0
- package/dist/temporal/constants.js.map +1 -1
- package/dist/temporal/workflows/cleanup-integration-secrets-cron.d.ts +2 -0
- package/dist/temporal/workflows/cleanup-integration-secrets-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/cleanup-integration-secrets-cron.js +21 -0
- package/dist/temporal/workflows/cleanup-integration-secrets-cron.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +64 -3
- 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/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_durable_secret_cleanup.sql +22 -0
- package/drizzle/meta/0001_snapshot.json +180 -2
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +11 -10
- package/src/core/secret-cleanup.test.ts +129 -0
- package/src/core/secret-cleanup.ts +229 -0
- package/src/db/db.ts +2 -0
- package/src/db/schema/secret-cleanups.ts +42 -0
- package/src/db/secret-cleanups.test.ts +113 -0
- package/src/db/secret-cleanups.ts +209 -0
- package/src/index.ts +6 -1
- package/src/presentation/routes/manage-connections.test.ts +232 -0
- package/src/presentation/routes/manage-connections.ts +45 -11
- package/src/providers/jira.ts +61 -13
- package/src/temporal/activities/index.ts +11 -1
- package/src/temporal/constants.ts +2 -0
- package/src/temporal/workflows/cleanup-integration-secrets-cron.ts +20 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/test/env.ts +3 -0
- package/test/globalSetup.ts +1 -0
- package/test/route-utils.ts +3 -0
- package/test/setup.ts +12 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {afterEach} from '@shipfox/vitest/vi';
|
|
2
|
+
import {sql} from 'drizzle-orm';
|
|
3
|
+
import {CLEANUP_SECRETS_ACTIVITY_TIMEOUT_MS} from '#temporal/constants.js';
|
|
4
|
+
import {upsertIntegrationConnection} from './connections.js';
|
|
5
|
+
import {db} from './db.js';
|
|
6
|
+
import {
|
|
7
|
+
claimIntegrationSecretCleanups,
|
|
8
|
+
completeIntegrationSecretCleanup,
|
|
9
|
+
enqueueIntegrationSecretCleanup,
|
|
10
|
+
retryIntegrationSecretCleanup,
|
|
11
|
+
} from './secret-cleanups.js';
|
|
12
|
+
|
|
13
|
+
const now = new Date(Date.now() + 60 * 1_000);
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await db().execute(sql`TRUNCATE integrations_secret_cleanups CASCADE`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('integration secret cleanup persistence', () => {
|
|
20
|
+
it('keeps the default lease longer than the cleanup activity timeout', async () => {
|
|
21
|
+
await createCleanupConnection();
|
|
22
|
+
|
|
23
|
+
const [claimed] = await claimIntegrationSecretCleanups({limit: 1, now});
|
|
24
|
+
|
|
25
|
+
if (!claimed?.leaseExpiresAt) throw new Error('Expected the cleanup to have a lease');
|
|
26
|
+
expect(claimed.leaseExpiresAt.getTime() - now.getTime()).toBeGreaterThan(
|
|
27
|
+
CLEANUP_SECRETS_ACTIVITY_TIMEOUT_MS,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('claims a due row once and reclaims it after its lease expires', async () => {
|
|
32
|
+
const connection = await createCleanupConnection();
|
|
33
|
+
const [queued] = await claimIntegrationSecretCleanups({limit: 1, now, leaseDurationMs: 1_000});
|
|
34
|
+
|
|
35
|
+
expect(queued).toMatchObject({
|
|
36
|
+
connectionId: connection.id,
|
|
37
|
+
attemptCount: 1,
|
|
38
|
+
leaseToken: expect.any(String),
|
|
39
|
+
});
|
|
40
|
+
await expect(claimIntegrationSecretCleanups({limit: 1, now})).resolves.toEqual([]);
|
|
41
|
+
|
|
42
|
+
const wrongLeaseToken = crypto.randomUUID();
|
|
43
|
+
await expect(
|
|
44
|
+
completeIntegrationSecretCleanup({
|
|
45
|
+
id: queued?.id ?? '',
|
|
46
|
+
leaseToken: wrongLeaseToken,
|
|
47
|
+
now,
|
|
48
|
+
}),
|
|
49
|
+
).resolves.toBe(false);
|
|
50
|
+
await expect(
|
|
51
|
+
retryIntegrationSecretCleanup({
|
|
52
|
+
id: queued?.id ?? '',
|
|
53
|
+
leaseToken: wrongLeaseToken,
|
|
54
|
+
delayMs: 1_000,
|
|
55
|
+
now,
|
|
56
|
+
}),
|
|
57
|
+
).resolves.toBe(false);
|
|
58
|
+
|
|
59
|
+
const [reclaimed] = await claimIntegrationSecretCleanups({
|
|
60
|
+
limit: 1,
|
|
61
|
+
now: new Date(now.getTime() + 1_001),
|
|
62
|
+
leaseDurationMs: 1_000,
|
|
63
|
+
});
|
|
64
|
+
expect(reclaimed).toMatchObject({
|
|
65
|
+
id: queued?.id,
|
|
66
|
+
attemptCount: 2,
|
|
67
|
+
leaseToken: expect.any(String),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await expect(
|
|
71
|
+
completeIntegrationSecretCleanup({
|
|
72
|
+
id: reclaimed?.id ?? '',
|
|
73
|
+
leaseToken: reclaimed?.leaseToken ?? '',
|
|
74
|
+
now: new Date(now.getTime() + 1_001),
|
|
75
|
+
}),
|
|
76
|
+
).resolves.toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('honors the batch limit and retry backoff', async () => {
|
|
80
|
+
await createCleanupConnection({externalAccountId: 'first'});
|
|
81
|
+
await createCleanupConnection({externalAccountId: 'second'});
|
|
82
|
+
|
|
83
|
+
const firstClaim = await claimIntegrationSecretCleanups({limit: 1, now});
|
|
84
|
+
expect(firstClaim).toHaveLength(1);
|
|
85
|
+
const first = firstClaim[0];
|
|
86
|
+
if (!first?.leaseToken) throw new Error('Expected the first cleanup to have a lease');
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
retryIntegrationSecretCleanup({
|
|
90
|
+
id: first.id,
|
|
91
|
+
leaseToken: first.leaseToken,
|
|
92
|
+
delayMs: 5_000,
|
|
93
|
+
now,
|
|
94
|
+
}),
|
|
95
|
+
).resolves.toBe(true);
|
|
96
|
+
|
|
97
|
+
const secondClaim = await claimIntegrationSecretCleanups({limit: 2, now});
|
|
98
|
+
expect(secondClaim).toHaveLength(1);
|
|
99
|
+
expect(secondClaim[0]?.id).not.toBe(first.id);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
async function createCleanupConnection(overrides: {externalAccountId?: string} = {}) {
|
|
104
|
+
const connection = await upsertIntegrationConnection({
|
|
105
|
+
workspaceId: crypto.randomUUID(),
|
|
106
|
+
provider: 'slack',
|
|
107
|
+
externalAccountId: overrides.externalAccountId ?? crypto.randomUUID(),
|
|
108
|
+
slug: `slack_${crypto.randomUUID()}`,
|
|
109
|
+
displayName: 'Slack',
|
|
110
|
+
});
|
|
111
|
+
await enqueueIntegrationSecretCleanup({connection});
|
|
112
|
+
return connection;
|
|
113
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {and, asc, eq, gt, inArray, isNull, lte, or, sql} from 'drizzle-orm';
|
|
2
|
+
import type {IntegrationConnection} from '#core/entities/connection.js';
|
|
3
|
+
import {db} from './db.js';
|
|
4
|
+
import {
|
|
5
|
+
type IntegrationSecretCleanupDb,
|
|
6
|
+
integrationSecretCleanups,
|
|
7
|
+
} from './schema/secret-cleanups.js';
|
|
8
|
+
|
|
9
|
+
// Keep the lease longer than cleanupIntegrationSecretsCron's 5-minute activity timeout so
|
|
10
|
+
// a timed-out sweep cannot have its claimed rows reclaimed while it is still running.
|
|
11
|
+
const DEFAULT_LEASE_DURATION_MS = 10 * 60 * 1_000;
|
|
12
|
+
|
|
13
|
+
export interface EnqueueIntegrationSecretCleanupParams {
|
|
14
|
+
connection: IntegrationConnection;
|
|
15
|
+
now?: Date | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IntegrationSecretCleanup {
|
|
19
|
+
id: string;
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
provider: string;
|
|
22
|
+
connectionId: string;
|
|
23
|
+
externalAccountId: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
lifecycleStatus: IntegrationConnection['lifecycleStatus'];
|
|
27
|
+
connectionCreatedAt: Date;
|
|
28
|
+
connectionUpdatedAt: Date;
|
|
29
|
+
attemptCount: number;
|
|
30
|
+
nextAttemptAt: Date;
|
|
31
|
+
leaseToken: string | null;
|
|
32
|
+
leaseExpiresAt: Date | null;
|
|
33
|
+
createdAt: Date;
|
|
34
|
+
updatedAt: Date;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type IntegrationDb = ReturnType<typeof db>;
|
|
38
|
+
type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
|
|
39
|
+
|
|
40
|
+
export async function enqueueIntegrationSecretCleanup(
|
|
41
|
+
params: EnqueueIntegrationSecretCleanupParams,
|
|
42
|
+
options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const executor = options.tx ?? db();
|
|
45
|
+
await executor
|
|
46
|
+
.insert(integrationSecretCleanups)
|
|
47
|
+
.values({
|
|
48
|
+
workspaceId: params.connection.workspaceId,
|
|
49
|
+
provider: params.connection.provider,
|
|
50
|
+
connectionId: params.connection.id,
|
|
51
|
+
externalAccountId: params.connection.externalAccountId,
|
|
52
|
+
slug: params.connection.slug,
|
|
53
|
+
displayName: params.connection.displayName,
|
|
54
|
+
lifecycleStatus: params.connection.lifecycleStatus,
|
|
55
|
+
connectionCreatedAt: params.connection.createdAt,
|
|
56
|
+
connectionUpdatedAt: params.connection.updatedAt,
|
|
57
|
+
// Claims compare against the application clock, so the first due time must come
|
|
58
|
+
// from that clock too. A database clock even milliseconds ahead would otherwise
|
|
59
|
+
// hide the row from the sweep that runs right after this insert commits.
|
|
60
|
+
nextAttemptAt: params.now ?? new Date(),
|
|
61
|
+
})
|
|
62
|
+
.onConflictDoNothing({
|
|
63
|
+
target: [integrationSecretCleanups.provider, integrationSecretCleanups.connectionId],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ClaimIntegrationSecretCleanupsParams {
|
|
68
|
+
limit: number;
|
|
69
|
+
connectionId?: string | undefined;
|
|
70
|
+
now?: Date | undefined;
|
|
71
|
+
leaseDurationMs?: number | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function claimIntegrationSecretCleanups(
|
|
75
|
+
params: ClaimIntegrationSecretCleanupsParams,
|
|
76
|
+
): Promise<IntegrationSecretCleanup[]> {
|
|
77
|
+
if (!Number.isInteger(params.limit) || params.limit <= 0) {
|
|
78
|
+
throw new Error('Secret cleanup claim limit must be a positive integer');
|
|
79
|
+
}
|
|
80
|
+
const now = params.now ?? new Date();
|
|
81
|
+
const leaseDurationMs = params.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
|
82
|
+
if (!Number.isInteger(leaseDurationMs) || leaseDurationMs <= 0) {
|
|
83
|
+
throw new Error('Secret cleanup lease duration must be a positive integer');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return await db().transaction(async (tx) => {
|
|
87
|
+
const conditions = [
|
|
88
|
+
lte(integrationSecretCleanups.nextAttemptAt, now),
|
|
89
|
+
or(
|
|
90
|
+
isNull(integrationSecretCleanups.leaseExpiresAt),
|
|
91
|
+
lte(integrationSecretCleanups.leaseExpiresAt, now),
|
|
92
|
+
),
|
|
93
|
+
...(params.connectionId
|
|
94
|
+
? [eq(integrationSecretCleanups.connectionId, params.connectionId)]
|
|
95
|
+
: []),
|
|
96
|
+
];
|
|
97
|
+
const candidates = await tx
|
|
98
|
+
.select({id: integrationSecretCleanups.id})
|
|
99
|
+
.from(integrationSecretCleanups)
|
|
100
|
+
.where(and(...conditions))
|
|
101
|
+
.orderBy(
|
|
102
|
+
asc(integrationSecretCleanups.nextAttemptAt),
|
|
103
|
+
asc(integrationSecretCleanups.createdAt),
|
|
104
|
+
asc(integrationSecretCleanups.id),
|
|
105
|
+
)
|
|
106
|
+
.limit(params.limit)
|
|
107
|
+
.for('update', {skipLocked: true});
|
|
108
|
+
if (candidates.length === 0) return [];
|
|
109
|
+
|
|
110
|
+
const rows = await tx
|
|
111
|
+
.update(integrationSecretCleanups)
|
|
112
|
+
.set({
|
|
113
|
+
attemptCount: sql`${integrationSecretCleanups.attemptCount} + 1`,
|
|
114
|
+
leaseToken: sql`gen_random_uuid()`,
|
|
115
|
+
leaseExpiresAt: new Date(now.getTime() + leaseDurationMs),
|
|
116
|
+
updatedAt: now,
|
|
117
|
+
})
|
|
118
|
+
.where(
|
|
119
|
+
inArray(
|
|
120
|
+
integrationSecretCleanups.id,
|
|
121
|
+
candidates.map((candidate) => candidate.id),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
.returning();
|
|
125
|
+
return rows.map(toIntegrationSecretCleanup);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function completeIntegrationSecretCleanup(params: {
|
|
130
|
+
id: string;
|
|
131
|
+
leaseToken: string;
|
|
132
|
+
now?: Date | undefined;
|
|
133
|
+
}): Promise<boolean> {
|
|
134
|
+
const now = params.now ?? new Date();
|
|
135
|
+
const result = await db()
|
|
136
|
+
.delete(integrationSecretCleanups)
|
|
137
|
+
.where(
|
|
138
|
+
and(
|
|
139
|
+
eq(integrationSecretCleanups.id, params.id),
|
|
140
|
+
eq(integrationSecretCleanups.leaseToken, params.leaseToken),
|
|
141
|
+
gt(integrationSecretCleanups.leaseExpiresAt, now),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
return (result.rowCount ?? 0) > 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function retryIntegrationSecretCleanup(params: {
|
|
148
|
+
id: string;
|
|
149
|
+
leaseToken: string;
|
|
150
|
+
delayMs: number;
|
|
151
|
+
now?: Date | undefined;
|
|
152
|
+
}): Promise<boolean> {
|
|
153
|
+
if (!Number.isInteger(params.delayMs) || params.delayMs < 0) {
|
|
154
|
+
throw new Error('Secret cleanup retry delay must be a non-negative integer');
|
|
155
|
+
}
|
|
156
|
+
const now = params.now ?? new Date();
|
|
157
|
+
const result = await db()
|
|
158
|
+
.update(integrationSecretCleanups)
|
|
159
|
+
.set({
|
|
160
|
+
nextAttemptAt: new Date(now.getTime() + params.delayMs),
|
|
161
|
+
leaseToken: null,
|
|
162
|
+
leaseExpiresAt: null,
|
|
163
|
+
updatedAt: now,
|
|
164
|
+
})
|
|
165
|
+
.where(
|
|
166
|
+
and(
|
|
167
|
+
eq(integrationSecretCleanups.id, params.id),
|
|
168
|
+
eq(integrationSecretCleanups.leaseToken, params.leaseToken),
|
|
169
|
+
gt(integrationSecretCleanups.leaseExpiresAt, now),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
return (result.rowCount ?? 0) > 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function listIntegrationSecretCleanups(
|
|
176
|
+
params: {connectionId?: string | undefined} = {},
|
|
177
|
+
): Promise<IntegrationSecretCleanup[]> {
|
|
178
|
+
const rows = await db()
|
|
179
|
+
.select()
|
|
180
|
+
.from(integrationSecretCleanups)
|
|
181
|
+
.where(
|
|
182
|
+
params.connectionId
|
|
183
|
+
? eq(integrationSecretCleanups.connectionId, params.connectionId)
|
|
184
|
+
: undefined,
|
|
185
|
+
)
|
|
186
|
+
.orderBy(asc(integrationSecretCleanups.createdAt), asc(integrationSecretCleanups.id));
|
|
187
|
+
return rows.map(toIntegrationSecretCleanup);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toIntegrationSecretCleanup(row: IntegrationSecretCleanupDb): IntegrationSecretCleanup {
|
|
191
|
+
return {
|
|
192
|
+
id: row.id,
|
|
193
|
+
workspaceId: row.workspaceId,
|
|
194
|
+
provider: row.provider,
|
|
195
|
+
connectionId: row.connectionId,
|
|
196
|
+
externalAccountId: row.externalAccountId,
|
|
197
|
+
slug: row.slug,
|
|
198
|
+
displayName: row.displayName,
|
|
199
|
+
lifecycleStatus: row.lifecycleStatus,
|
|
200
|
+
connectionCreatedAt: row.connectionCreatedAt,
|
|
201
|
+
connectionUpdatedAt: row.connectionUpdatedAt,
|
|
202
|
+
attemptCount: row.attemptCount,
|
|
203
|
+
nextAttemptAt: row.nextAttemptAt,
|
|
204
|
+
leaseToken: row.leaseToken,
|
|
205
|
+
leaseExpiresAt: row.leaseExpiresAt,
|
|
206
|
+
createdAt: row.createdAt,
|
|
207
|
+
updatedAt: row.updatedAt,
|
|
208
|
+
};
|
|
209
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -267,13 +267,18 @@ export async function createIntegrationsContext(
|
|
|
267
267
|
{
|
|
268
268
|
taskQueue: INTEGRATIONS_MAINTENANCE_TASK_QUEUE,
|
|
269
269
|
workflowsPath: maintenanceWorkflowsPath,
|
|
270
|
-
activities: createIntegrationsMaintenanceActivities,
|
|
270
|
+
activities: () => createIntegrationsMaintenanceActivities({registry}),
|
|
271
271
|
workflows: [
|
|
272
272
|
{
|
|
273
273
|
name: 'pruneWebhookDeliveriesCron',
|
|
274
274
|
id: 'integrations-prune-webhook-deliveries',
|
|
275
275
|
cronSchedule: '0 3 * * *',
|
|
276
276
|
},
|
|
277
|
+
{
|
|
278
|
+
name: 'cleanupIntegrationSecretsCron',
|
|
279
|
+
id: 'integrations-cleanup-secret-namespaces',
|
|
280
|
+
cronSchedule: '*/5 * * * *',
|
|
281
|
+
},
|
|
277
282
|
],
|
|
278
283
|
},
|
|
279
284
|
...parts.flatMap((part) => part.workers ?? []),
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import {createIntegrationProviderRegistry} from '#core/providers/registry.js';
|
|
2
|
+
import {processIntegrationSecretCleanups} from '#core/secret-cleanup.js';
|
|
1
3
|
import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
|
|
4
|
+
import {listIntegrationSecretCleanups} from '#db/secret-cleanups.js';
|
|
2
5
|
import {createTestApp, sourceProvider, useIntegrationRouteTest} from '#test/route-utils.js';
|
|
3
6
|
|
|
4
7
|
describe('PATCH /integration-connections/:connectionId', () => {
|
|
@@ -111,6 +114,7 @@ describe('DELETE /integration-connections/:connectionId', () => {
|
|
|
111
114
|
});
|
|
112
115
|
|
|
113
116
|
it('runs provider cleanup hooks while retaining ownership of the core row', async () => {
|
|
117
|
+
const deleteConnectionRemoteResources = vi.fn(() => Promise.resolve(undefined));
|
|
114
118
|
const deleteConnectionRecords = vi.fn(() => Promise.resolve());
|
|
115
119
|
const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
|
|
116
120
|
const app = await createTestApp([
|
|
@@ -118,6 +122,7 @@ describe('DELETE /integration-connections/:connectionId', () => {
|
|
|
118
122
|
provider: 'slack',
|
|
119
123
|
displayName: 'Slack',
|
|
120
124
|
adapters: {},
|
|
125
|
+
deleteConnectionRemoteResources,
|
|
121
126
|
deleteConnectionRecords,
|
|
122
127
|
deleteConnectionSecrets,
|
|
123
128
|
}),
|
|
@@ -137,6 +142,7 @@ describe('DELETE /integration-connections/:connectionId', () => {
|
|
|
137
142
|
});
|
|
138
143
|
|
|
139
144
|
expect(res.statusCode).toBe(204);
|
|
145
|
+
expect(deleteConnectionRemoteResources).toHaveBeenCalledWith(connection);
|
|
140
146
|
expect(deleteConnectionRecords).toHaveBeenCalledWith(connection, {
|
|
141
147
|
tx: expect.anything(),
|
|
142
148
|
});
|
|
@@ -144,6 +150,103 @@ describe('DELETE /integration-connections/:connectionId', () => {
|
|
|
144
150
|
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
145
151
|
});
|
|
146
152
|
|
|
153
|
+
it('runs prepared remote cleanup after the local deletion commits', async () => {
|
|
154
|
+
const events: string[] = [];
|
|
155
|
+
const deleteConnectionRemoteResources = vi.fn(() => {
|
|
156
|
+
events.push('prepare');
|
|
157
|
+
return Promise.resolve(async () => {
|
|
158
|
+
events.push('remote');
|
|
159
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
const deleteConnectionRecords = vi.fn(() => {
|
|
163
|
+
events.push('records');
|
|
164
|
+
return Promise.resolve();
|
|
165
|
+
});
|
|
166
|
+
const deleteConnectionSecrets = vi.fn(() => {
|
|
167
|
+
events.push('secrets');
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
});
|
|
170
|
+
const app = await createTestApp([
|
|
171
|
+
sourceProvider({
|
|
172
|
+
provider: 'slack',
|
|
173
|
+
displayName: 'Slack',
|
|
174
|
+
adapters: {},
|
|
175
|
+
deleteConnectionRemoteResources,
|
|
176
|
+
deleteConnectionRecords,
|
|
177
|
+
deleteConnectionSecrets,
|
|
178
|
+
}),
|
|
179
|
+
]);
|
|
180
|
+
const connection = await upsertIntegrationConnection({
|
|
181
|
+
workspaceId: context.workspaceId,
|
|
182
|
+
provider: 'slack',
|
|
183
|
+
externalAccountId: 'T123',
|
|
184
|
+
slug: 'slack_acme',
|
|
185
|
+
displayName: 'Slack Acme',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const res = await app.inject({
|
|
189
|
+
method: 'DELETE',
|
|
190
|
+
url: `/integration-connections/${connection.id}`,
|
|
191
|
+
headers: {authorization: 'Bearer user'},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(res.statusCode).toBe(204);
|
|
195
|
+
expect(events).toEqual(['prepare', 'records', 'remote', 'secrets']);
|
|
196
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('holds the provider deletion lock across local and remote cleanup', async () => {
|
|
200
|
+
const events: string[] = [];
|
|
201
|
+
const withConnectionDeletionLock = vi.fn(
|
|
202
|
+
async (_connection: unknown, fn: () => Promise<void>) => {
|
|
203
|
+
events.push('lock-enter');
|
|
204
|
+
await fn();
|
|
205
|
+
events.push('lock-exit');
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
const app = await createTestApp([
|
|
209
|
+
sourceProvider({
|
|
210
|
+
provider: 'slack',
|
|
211
|
+
displayName: 'Slack',
|
|
212
|
+
adapters: {},
|
|
213
|
+
withConnectionDeletionLock,
|
|
214
|
+
deleteConnectionRemoteResources: vi.fn(() => {
|
|
215
|
+
events.push('prepare');
|
|
216
|
+
return Promise.resolve(() => {
|
|
217
|
+
events.push('remote');
|
|
218
|
+
return Promise.resolve();
|
|
219
|
+
});
|
|
220
|
+
}),
|
|
221
|
+
deleteConnectionRecords: vi.fn(() => {
|
|
222
|
+
events.push('records');
|
|
223
|
+
return Promise.resolve();
|
|
224
|
+
}),
|
|
225
|
+
deleteConnectionSecrets: vi.fn(() => {
|
|
226
|
+
events.push('secrets');
|
|
227
|
+
return Promise.resolve();
|
|
228
|
+
}),
|
|
229
|
+
}),
|
|
230
|
+
]);
|
|
231
|
+
const connection = await upsertIntegrationConnection({
|
|
232
|
+
workspaceId: context.workspaceId,
|
|
233
|
+
provider: 'slack',
|
|
234
|
+
externalAccountId: 'T123',
|
|
235
|
+
slug: 'slack_acme',
|
|
236
|
+
displayName: 'Slack Acme',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const res = await app.inject({
|
|
240
|
+
method: 'DELETE',
|
|
241
|
+
url: `/integration-connections/${connection.id}`,
|
|
242
|
+
headers: {authorization: 'Bearer user'},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(res.statusCode).toBe(204);
|
|
246
|
+
expect(events).toEqual(['lock-enter', 'prepare', 'records', 'remote', 'secrets', 'lock-exit']);
|
|
247
|
+
expect(withConnectionDeletionLock).toHaveBeenCalledWith(connection, expect.any(Function));
|
|
248
|
+
});
|
|
249
|
+
|
|
147
250
|
it('keeps the connection when provider record cleanup fails', async () => {
|
|
148
251
|
const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
|
|
149
252
|
const app = await createTestApp([
|
|
@@ -176,6 +279,68 @@ describe('DELETE /integration-connections/:connectionId', () => {
|
|
|
176
279
|
expect(deleteConnectionSecrets).not.toHaveBeenCalled();
|
|
177
280
|
});
|
|
178
281
|
|
|
282
|
+
it('continues connection deletion when remote cleanup preparation fails', async () => {
|
|
283
|
+
const deleteConnectionRemoteResources = vi.fn(() =>
|
|
284
|
+
Promise.reject(new Error('remote cleanup failed')),
|
|
285
|
+
);
|
|
286
|
+
const app = await createTestApp([
|
|
287
|
+
sourceProvider({
|
|
288
|
+
provider: 'slack',
|
|
289
|
+
displayName: 'Slack',
|
|
290
|
+
adapters: {},
|
|
291
|
+
deleteConnectionRemoteResources,
|
|
292
|
+
}),
|
|
293
|
+
]);
|
|
294
|
+
const connection = await upsertIntegrationConnection({
|
|
295
|
+
workspaceId: context.workspaceId,
|
|
296
|
+
provider: 'slack',
|
|
297
|
+
externalAccountId: 'T123',
|
|
298
|
+
slug: 'slack_acme',
|
|
299
|
+
displayName: 'Slack Acme',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const res = await app.inject({
|
|
303
|
+
method: 'DELETE',
|
|
304
|
+
url: `/integration-connections/${connection.id}`,
|
|
305
|
+
headers: {authorization: 'Bearer user'},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(res.statusCode).toBe(204);
|
|
309
|
+
expect(deleteConnectionRemoteResources).toHaveBeenCalledWith(connection);
|
|
310
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('continues connection deletion when post-commit remote cleanup fails', async () => {
|
|
314
|
+
const deleteConnectionRemoteResources = vi.fn(() =>
|
|
315
|
+
Promise.resolve(() => Promise.reject(new Error('remote cleanup failed'))),
|
|
316
|
+
);
|
|
317
|
+
const app = await createTestApp([
|
|
318
|
+
sourceProvider({
|
|
319
|
+
provider: 'slack',
|
|
320
|
+
displayName: 'Slack',
|
|
321
|
+
adapters: {},
|
|
322
|
+
deleteConnectionRemoteResources,
|
|
323
|
+
}),
|
|
324
|
+
]);
|
|
325
|
+
const connection = await upsertIntegrationConnection({
|
|
326
|
+
workspaceId: context.workspaceId,
|
|
327
|
+
provider: 'slack',
|
|
328
|
+
externalAccountId: 'T123',
|
|
329
|
+
slug: 'slack_acme',
|
|
330
|
+
displayName: 'Slack Acme',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const res = await app.inject({
|
|
334
|
+
method: 'DELETE',
|
|
335
|
+
url: `/integration-connections/${connection.id}`,
|
|
336
|
+
headers: {authorization: 'Bearer user'},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(res.statusCode).toBe(204);
|
|
340
|
+
expect(deleteConnectionRemoteResources).toHaveBeenCalledWith(connection);
|
|
341
|
+
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
179
344
|
it('deletes the connection when provider secret cleanup fails after commit', async () => {
|
|
180
345
|
const app = await createTestApp([
|
|
181
346
|
sourceProvider({
|
|
@@ -201,6 +366,73 @@ describe('DELETE /integration-connections/:connectionId', () => {
|
|
|
201
366
|
|
|
202
367
|
expect(res.statusCode).toBe(204);
|
|
203
368
|
await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
|
|
369
|
+
await expect(
|
|
370
|
+
listIntegrationSecretCleanups({connectionId: connection.id}),
|
|
371
|
+
).resolves.toMatchObject([
|
|
372
|
+
{
|
|
373
|
+
provider: 'slack',
|
|
374
|
+
connectionId: connection.id,
|
|
375
|
+
attemptCount: 1,
|
|
376
|
+
leaseToken: null,
|
|
377
|
+
},
|
|
378
|
+
]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('retries provider secret cleanup from a durable post-commit record', async () => {
|
|
382
|
+
const deleteConnectionSecrets = vi
|
|
383
|
+
.fn<() => Promise<void>>()
|
|
384
|
+
.mockRejectedValueOnce(new Error('transient cleanup failure'))
|
|
385
|
+
.mockResolvedValue(undefined);
|
|
386
|
+
const provider = sourceProvider({
|
|
387
|
+
provider: 'slack',
|
|
388
|
+
displayName: 'Slack',
|
|
389
|
+
adapters: {},
|
|
390
|
+
deleteConnectionSecrets,
|
|
391
|
+
});
|
|
392
|
+
const app = await createTestApp([provider]);
|
|
393
|
+
const connection = await upsertIntegrationConnection({
|
|
394
|
+
workspaceId: context.workspaceId,
|
|
395
|
+
provider: 'slack',
|
|
396
|
+
externalAccountId: 'T123',
|
|
397
|
+
slug: 'slack_acme',
|
|
398
|
+
displayName: 'Slack Acme',
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const res = await app.inject({
|
|
402
|
+
method: 'DELETE',
|
|
403
|
+
url: `/integration-connections/${connection.id}`,
|
|
404
|
+
headers: {authorization: 'Bearer user'},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const pending = await listIntegrationSecretCleanups({connectionId: connection.id});
|
|
408
|
+
expect(res.statusCode).toBe(204);
|
|
409
|
+
expect(pending).toHaveLength(1);
|
|
410
|
+
expect(pending[0]).toMatchObject({
|
|
411
|
+
workspaceId: context.workspaceId,
|
|
412
|
+
provider: 'slack',
|
|
413
|
+
connectionId: connection.id,
|
|
414
|
+
attemptCount: 1,
|
|
415
|
+
leaseToken: null,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const result = await processIntegrationSecretCleanups({
|
|
419
|
+
registry: createIntegrationProviderRegistry([provider]),
|
|
420
|
+
connectionId: connection.id,
|
|
421
|
+
now: new Date(Date.now() + 5 * 60 * 1_000),
|
|
422
|
+
limit: 1,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
expect(result).toEqual({
|
|
426
|
+
claimed: 1,
|
|
427
|
+
completed: 1,
|
|
428
|
+
failed: 0,
|
|
429
|
+
unavailable: 0,
|
|
430
|
+
unacknowledged: 0,
|
|
431
|
+
});
|
|
432
|
+
await expect(listIntegrationSecretCleanups({connectionId: connection.id})).resolves.toEqual([]);
|
|
433
|
+
expect(deleteConnectionSecrets).toHaveBeenCalledTimes(2);
|
|
434
|
+
expect(deleteConnectionSecrets).toHaveBeenNthCalledWith(1, connection);
|
|
435
|
+
expect(deleteConnectionSecrets).toHaveBeenNthCalledWith(2, connection);
|
|
204
436
|
});
|
|
205
437
|
|
|
206
438
|
it('deletes an unregistered provider connection without provider cleanup', async () => {
|
|
@@ -6,12 +6,14 @@ import {
|
|
|
6
6
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
7
7
|
import {z} from 'zod';
|
|
8
8
|
import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
|
|
9
|
+
import {processIntegrationSecretCleanups} from '#core/secret-cleanup.js';
|
|
9
10
|
import {
|
|
10
11
|
deleteIntegrationConnection,
|
|
11
12
|
getIntegrationConnectionById,
|
|
12
13
|
updateIntegrationConnectionLifecycleStatus,
|
|
13
14
|
} from '#db/connections.js';
|
|
14
15
|
import {db} from '#db/db.js';
|
|
16
|
+
import {enqueueIntegrationSecretCleanup} from '#db/secret-cleanups.js';
|
|
15
17
|
import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
|
|
16
18
|
|
|
17
19
|
const connectionParamsSchema = z.object({
|
|
@@ -75,6 +77,7 @@ export function createDeleteIntegrationConnectionRoute(registry: IntegrationProv
|
|
|
75
77
|
.list()
|
|
76
78
|
.find((candidate) => candidate.provider === connection.provider);
|
|
77
79
|
const hasCleanupHooks =
|
|
80
|
+
provider?.deleteConnectionRemoteResources !== undefined ||
|
|
78
81
|
provider?.deleteConnectionRecords !== undefined ||
|
|
79
82
|
provider?.deleteConnectionSecrets !== undefined;
|
|
80
83
|
if (!hasCleanupHooks) {
|
|
@@ -83,17 +86,48 @@ export function createDeleteIntegrationConnectionRoute(registry: IntegrationProv
|
|
|
83
86
|
'Deleting integration connection without provider cleanup',
|
|
84
87
|
);
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
89
|
+
const deleteConnection = async (): Promise<void> => {
|
|
90
|
+
let deleteRemoteResources: (() => Promise<void>) | undefined;
|
|
91
|
+
try {
|
|
92
|
+
deleteRemoteResources = await provider?.deleteConnectionRemoteResources?.(connection);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
request.log.error(
|
|
95
|
+
{connectionId: connection.id, provider: connection.provider, err: error},
|
|
96
|
+
'Integration connection remote cleanup preparation failed',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
await db().transaction(async (tx) => {
|
|
100
|
+
await provider?.deleteConnectionRecords?.(connection, {tx});
|
|
101
|
+
await enqueueIntegrationSecretCleanup({connection}, {tx});
|
|
102
|
+
await deleteIntegrationConnection({id: connection.id}, {tx});
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
await deleteRemoteResources?.();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
request.log.error(
|
|
108
|
+
{connectionId: connection.id, provider: connection.provider, err: error},
|
|
109
|
+
'Integration connection remote cleanup failed after deletion',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await processIntegrationSecretCleanups({
|
|
114
|
+
registry,
|
|
115
|
+
connectionId: connection.id,
|
|
116
|
+
connection,
|
|
117
|
+
limit: 1,
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// The durable cleanup row survives, so a later sweep retries this connection.
|
|
121
|
+
request.log.error(
|
|
122
|
+
{connectionId: connection.id, provider: connection.provider, err: error},
|
|
123
|
+
'Integration connection secret cleanup failed after connection deletion',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
if (provider?.withConnectionDeletionLock) {
|
|
128
|
+
await provider.withConnectionDeletionLock(connection, deleteConnection);
|
|
129
|
+
} else {
|
|
130
|
+
await deleteConnection();
|
|
97
131
|
}
|
|
98
132
|
reply.status(204);
|
|
99
133
|
},
|