From e9596fc95e9bd8332fe01f5b4d759457e7e5a94f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 16 Sep 2026 10:40:55 -0700 Subject: [PATCH 1/2] fix(credentials): restore scoped Slack bot reconnects --- .../scoped-credentials-mutations.test.ts | 104 ++++++++++++++++++ apps/sim/hooks/queries/scoped-credentials.ts | 6 +- .../api/contracts/organization-credentials.ts | 7 +- 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 apps/sim/hooks/queries/scoped-credentials-mutations.test.ts diff --git a/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts b/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts new file mode 100644 index 00000000000..b91c1d81e84 --- /dev/null +++ b/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts @@ -0,0 +1,104 @@ +/** @vitest-environment node */ +import { setupGlobalFetchMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: (options: { mutationFn: (input: TInput) => Promise }) => ({ + mutateAsync: options.mutationFn, + }), +})) +vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ + oauthCredentialKeys: { lists: () => ['oauth-credentials', 'list'] }, +})) + +import { updateOrganizationCredentialBodySchema } from '@/lib/api/contracts/organization-credentials' +import { useUpdateScopedCredential } from '@/hooks/queries/scoped-credentials' + +const WORKSPACE_ID = 'workspace-1' +const ORGANIZATION_ID = 'organization-1' +const CREDENTIAL_ID = 'slack-bot-1' +const reconnectFields = { + signingSecret: 'new-signing-secret', + botToken: 'xoxb-new-bot-token', + displayName: 'Support Bot', + description: 'Reconnected Slack bot', +} +const credential = { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: reconnectFields.displayName, + description: reconnectFields.description, + unredacted: false, + providerId: 'slack-custom-bot', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('scoped Slack bot reconnect requests', () => { + it.each([WORKSPACE_ID, undefined])( + 'sends workspace scope %s only in the query when reconnecting the existing credential', + async (workspaceId) => { + const fetch = setupGlobalFetchMock({ json: { credential } }) + + await expect( + useUpdateScopedCredential().mutateAsync({ + credentialId: CREDENTIAL_ID, + workspaceId, + ...reconnectFields, + }) + ).resolves.toEqual({ credential }) + + expect(fetch).toHaveBeenCalledExactlyOnceWith( + `/api/credentials/${CREDENTIAL_ID}${workspaceId ? `?workspaceId=${workspaceId}` : ''}`, + expect.objectContaining({ method: 'PUT' }) + ) + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual(reconnectFields) + } + ) + + it('includes organization scope in the body when reconnecting the existing credential', async () => { + const organizationCredential = { + ...credential, + workspaceId: null, + organizationId: ORGANIZATION_ID, + } + const fetch = setupGlobalFetchMock({ json: { credential: organizationCredential } }) + + await expect( + useUpdateScopedCredential().mutateAsync({ + credentialId: CREDENTIAL_ID, + organizationId: ORGANIZATION_ID, + ...reconnectFields, + }) + ).resolves.toEqual({ credential: organizationCredential }) + + expect(fetch).toHaveBeenCalledExactlyOnceWith( + `/api/organization-credentials/${CREDENTIAL_ID}`, + expect.objectContaining({ method: 'PATCH' }) + ) + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual({ + ...reconnectFields, + organizationId: ORGANIZATION_ID, + }) + }) + + it.each([ + { organizationId: ORGANIZATION_ID }, + { ...reconnectFields }, + { organizationId: ORGANIZATION_ID, ...reconnectFields, workspaceId: WORKSPACE_ID }, + { organizationId: ORGANIZATION_ID, ...reconnectFields, unexpected: true }, + ])('rejects invalid organization updates: %j', (body) => { + expect(updateOrganizationCredentialBodySchema.safeParse(body).success).toBe(false) + }) +}) diff --git a/apps/sim/hooks/queries/scoped-credentials.ts b/apps/sim/hooks/queries/scoped-credentials.ts index 93eaa1e84be..d9cf1987f42 100644 --- a/apps/sim/hooks/queries/scoped-credentials.ts +++ b/apps/sim/hooks/queries/scoped-credentials.ts @@ -96,10 +96,10 @@ export function useUpdateScopedCredential() { workspaceId?: string organizationId?: never }) - | UpdateOrganizationCredentialBody + | (UpdateOrganizationCredentialBody & { workspaceId?: never }) ) ) => { - const { credentialId, ...body } = input + const { credentialId, workspaceId, ...body } = input if ('organizationId' in body && body.organizationId) return requestJson(updateOrganizationCredentialContract, { params: { id: credentialId }, @@ -108,7 +108,7 @@ export function useUpdateScopedCredential() { return requestJson(updateWorkspaceCredentialContract, { params: { id: credentialId }, body, - query: { workspaceId: 'workspaceId' in input ? input.workspaceId : undefined }, + query: { workspaceId }, }) }, onSuccess: reconcile, diff --git a/apps/sim/lib/api/contracts/organization-credentials.ts b/apps/sim/lib/api/contracts/organization-credentials.ts index 31fe71c5528..a84dae50f6b 100644 --- a/apps/sim/lib/api/contracts/organization-credentials.ts +++ b/apps/sim/lib/api/contracts/organization-credentials.ts @@ -90,10 +90,9 @@ export const createOrganizationCredentialDraftContract = defineRouteContract({ }, }) -export const updateOrganizationCredentialBodySchema = z.intersection( - updateCredentialByIdBodySchema, - z.object({ organizationId: organizationIdSchema }) -) +export const updateOrganizationCredentialBodySchema = updateCredentialByIdBodySchema.safeExtend({ + organizationId: organizationIdSchema, +}) export type UpdateOrganizationCredentialBody = z.input< typeof updateOrganizationCredentialBodySchema > From f5684d6a7f99ebcfef9df8c595e4e52856774835 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 16 Sep 2026 10:46:50 -0700 Subject: [PATCH 2/2] chore(credentials): add const assertions to reconnect fixtures --- apps/sim/hooks/queries/scoped-credentials-mutations.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts b/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts index b91c1d81e84..fd29a28f7ef 100644 --- a/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts +++ b/apps/sim/hooks/queries/scoped-credentials-mutations.test.ts @@ -24,7 +24,7 @@ const reconnectFields = { botToken: 'xoxb-new-bot-token', displayName: 'Support Bot', description: 'Reconnected Slack bot', -} +} as const const credential = { id: CREDENTIAL_ID, workspaceId: WORKSPACE_ID, @@ -39,7 +39,7 @@ const credential = { createdBy: 'user-1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', -} +} as const beforeEach(() => { vi.clearAllMocks()