Skip to content

Commit 7fcf26f

Browse files
fix(credentials): make disconnect idempotent
1 parent e149636 commit 7fcf26f

4 files changed

Lines changed: 47 additions & 26 deletions

File tree

apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,19 @@ describe('deleteConnectionCredential', () => {
5353
it('deletes exactly one credential within its canonical workspace scope', async () => {
5454
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }])
5555

56-
await deleteConnectionCredential({
56+
const deleted = await deleteConnectionCredential({
5757
credentialId: 'credential-1',
5858
workspaceId: 'workspace-1',
5959
reason: 'user_delete',
6060
})
6161

62+
expect(deleted).toBe(true)
6263
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential)
6364
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1')
6465
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1')
6566
})
6667

67-
it('fails fast if the authorized credential disappears before deletion commits', async () => {
68+
it('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => {
6869
dbChainMockFns.returning.mockResolvedValueOnce([])
6970

7071
await expect(
@@ -73,6 +74,6 @@ describe('deleteConnectionCredential', () => {
7374
workspaceId: 'workspace-1',
7475
reason: 'user_delete',
7576
})
76-
).rejects.toThrow('Credential disappeared during deletion')
77+
).resolves.toBe(false)
7778
})
7879
})

apps/sim/lib/credentials/application/service-account.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ describe('credential service-account application operations', () => {
9292
auditMetadata: { tenantId: 'tenant-1' },
9393
})
9494
mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }])
95+
mocks.delete.mockResolvedValue(true)
9596
mocks.requireProvider.mockReturnValue({
9697
type: 'service_account',
9798
providerId: 'zoom-service-account',
@@ -199,11 +200,22 @@ describe('credential service-account application operations', () => {
199200
input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
200201
})
201202

202-
expect(result).toEqual({ credential })
203+
expect(result).toEqual({ credential, deleted: true })
203204
expect(mocks.delete).toHaveBeenCalledWith({
204205
credentialId: credential.id,
205206
workspaceId: WORKSPACE_ID,
206207
reason: 'user_delete',
207208
})
208209
})
210+
211+
it('treats a concurrent disconnect as an idempotent success', async () => {
212+
mocks.delete.mockResolvedValue(false)
213+
214+
const result = await deleteCredentialUseCase.execute({
215+
principal,
216+
input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
217+
})
218+
219+
expect(result).toEqual({ credential, deleted: false })
220+
})
209221
})

apps/sim/lib/credentials/application/service-account.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export interface DeleteCredentialInput {
141141

142142
export interface DeleteCredentialResult {
143143
credential: CredentialRow
144+
deleted: boolean
144145
}
145146

146147
async function resolveCredentialContext(
@@ -176,27 +177,31 @@ export const deleteCredentialUseCase = defineAuthorizedWorkspaceUseCase({
176177
)
177178
}
178179

179-
await deleteConnectionCredential({
180+
const deleted = await deleteConnectionCredential({
180181
credentialId: input.credentialId,
181182
workspaceId: context.workspaceId,
182183
reason: 'user_delete',
183184
})
184-
return { credential: context.credential }
185+
return { credential: context.credential, deleted }
185186
},
186-
projectAudit: ({ result }) => ({
187-
action: AuditAction.CREDENTIAL_DELETED,
188-
resourceType: AuditResourceType.CREDENTIAL,
189-
resourceId: result.credential.id,
190-
resourceName: result.credential.displayName,
191-
description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`,
192-
metadata: {
193-
reason: 'user_delete',
194-
credentialType: result.credential.type,
195-
providerId: result.credential.providerId,
196-
accountId: result.credential.accountId,
197-
},
198-
}),
187+
projectAudit: ({ result }) =>
188+
result.deleted
189+
? {
190+
action: AuditAction.CREDENTIAL_DELETED,
191+
resourceType: AuditResourceType.CREDENTIAL,
192+
resourceId: result.credential.id,
193+
resourceName: result.credential.displayName,
194+
description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`,
195+
metadata: {
196+
reason: 'user_delete',
197+
credentialType: result.credential.type,
198+
providerId: result.credential.providerId,
199+
accountId: result.credential.accountId,
200+
},
201+
}
202+
: [],
199203
afterSuccess: ({ principal, context, result }) => {
204+
if (!result.deleted) return
200205
captureServerEvent(
201206
principalUserId(principal),
202207
'credential_deleted',

apps/sim/lib/credentials/deletion.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise<
8080
/** Clears references and deletes one connection without surface audit attribution. */
8181
export async function deleteConnectionCredential(
8282
params: DeleteConnectionCredentialParams
83-
): Promise<void> {
83+
): Promise<boolean> {
8484
const { credentialId, workspaceId } = params
8585
await clearCredentialRefs(credentialId, workspaceId)
8686
const deleted = await db
@@ -89,13 +89,16 @@ export async function deleteConnectionCredential(
8989
and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId))
9090
)
9191
.returning({ id: schema.credential.id })
92-
if (deleted.length !== 1) throw new Error('Credential disappeared during deletion')
92+
if (deleted.length > 1) throw new Error('Credential deletion affected multiple rows')
9393

94-
logger.info('Deleted credential', {
95-
credentialId,
96-
workspaceId,
97-
reason: params.reason,
98-
})
94+
if (deleted.length === 1) {
95+
logger.info('Deleted credential', {
96+
credentialId,
97+
workspaceId,
98+
reason: params.reason,
99+
})
100+
}
101+
return deleted.length === 1
99102
}
100103

101104
/**

0 commit comments

Comments
 (0)