Skip to content

Commit 360d653

Browse files
fix(credentials): restore scoped Slack bot reconnects (#7879)
* fix(credentials): restore scoped Slack bot reconnects * chore(credentials): add const assertions to reconnect fixtures
1 parent 31ad74b commit 360d653

3 files changed

Lines changed: 110 additions & 7 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/** @vitest-environment node */
2+
import { setupGlobalFetchMock } from '@sim/testing'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
vi.mock('@tanstack/react-query', () => ({
6+
useQuery: vi.fn(),
7+
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
8+
useMutation: <TInput, TOutput>(options: { mutationFn: (input: TInput) => Promise<TOutput> }) => ({
9+
mutateAsync: options.mutationFn,
10+
}),
11+
}))
12+
vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({
13+
oauthCredentialKeys: { lists: () => ['oauth-credentials', 'list'] },
14+
}))
15+
16+
import { updateOrganizationCredentialBodySchema } from '@/lib/api/contracts/organization-credentials'
17+
import { useUpdateScopedCredential } from '@/hooks/queries/scoped-credentials'
18+
19+
const WORKSPACE_ID = 'workspace-1'
20+
const ORGANIZATION_ID = 'organization-1'
21+
const CREDENTIAL_ID = 'slack-bot-1'
22+
const reconnectFields = {
23+
signingSecret: 'new-signing-secret',
24+
botToken: 'xoxb-new-bot-token',
25+
displayName: 'Support Bot',
26+
description: 'Reconnected Slack bot',
27+
} as const
28+
const credential = {
29+
id: CREDENTIAL_ID,
30+
workspaceId: WORKSPACE_ID,
31+
type: 'service_account',
32+
displayName: reconnectFields.displayName,
33+
description: reconnectFields.description,
34+
unredacted: false,
35+
providerId: 'slack-custom-bot',
36+
accountId: null,
37+
envKey: null,
38+
envOwnerUserId: null,
39+
createdBy: 'user-1',
40+
createdAt: '2026-01-01T00:00:00.000Z',
41+
updatedAt: '2026-01-02T00:00:00.000Z',
42+
} as const
43+
44+
beforeEach(() => {
45+
vi.clearAllMocks()
46+
})
47+
48+
describe('scoped Slack bot reconnect requests', () => {
49+
it.each([WORKSPACE_ID, undefined])(
50+
'sends workspace scope %s only in the query when reconnecting the existing credential',
51+
async (workspaceId) => {
52+
const fetch = setupGlobalFetchMock({ json: { credential } })
53+
54+
await expect(
55+
useUpdateScopedCredential().mutateAsync({
56+
credentialId: CREDENTIAL_ID,
57+
workspaceId,
58+
...reconnectFields,
59+
})
60+
).resolves.toEqual({ credential })
61+
62+
expect(fetch).toHaveBeenCalledExactlyOnceWith(
63+
`/api/credentials/${CREDENTIAL_ID}${workspaceId ? `?workspaceId=${workspaceId}` : ''}`,
64+
expect.objectContaining({ method: 'PUT' })
65+
)
66+
expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual(reconnectFields)
67+
}
68+
)
69+
70+
it('includes organization scope in the body when reconnecting the existing credential', async () => {
71+
const organizationCredential = {
72+
...credential,
73+
workspaceId: null,
74+
organizationId: ORGANIZATION_ID,
75+
}
76+
const fetch = setupGlobalFetchMock({ json: { credential: organizationCredential } })
77+
78+
await expect(
79+
useUpdateScopedCredential().mutateAsync({
80+
credentialId: CREDENTIAL_ID,
81+
organizationId: ORGANIZATION_ID,
82+
...reconnectFields,
83+
})
84+
).resolves.toEqual({ credential: organizationCredential })
85+
86+
expect(fetch).toHaveBeenCalledExactlyOnceWith(
87+
`/api/organization-credentials/${CREDENTIAL_ID}`,
88+
expect.objectContaining({ method: 'PATCH' })
89+
)
90+
expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual({
91+
...reconnectFields,
92+
organizationId: ORGANIZATION_ID,
93+
})
94+
})
95+
96+
it.each([
97+
{ organizationId: ORGANIZATION_ID },
98+
{ ...reconnectFields },
99+
{ organizationId: ORGANIZATION_ID, ...reconnectFields, workspaceId: WORKSPACE_ID },
100+
{ organizationId: ORGANIZATION_ID, ...reconnectFields, unexpected: true },
101+
])('rejects invalid organization updates: %j', (body) => {
102+
expect(updateOrganizationCredentialBodySchema.safeParse(body).success).toBe(false)
103+
})
104+
})

apps/sim/hooks/queries/scoped-credentials.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,10 @@ export function useUpdateScopedCredential() {
9696
workspaceId?: string
9797
organizationId?: never
9898
})
99-
| UpdateOrganizationCredentialBody
99+
| (UpdateOrganizationCredentialBody & { workspaceId?: never })
100100
)
101101
) => {
102-
const { credentialId, ...body } = input
102+
const { credentialId, workspaceId, ...body } = input
103103
if ('organizationId' in body && body.organizationId)
104104
return requestJson(updateOrganizationCredentialContract, {
105105
params: { id: credentialId },
@@ -108,7 +108,7 @@ export function useUpdateScopedCredential() {
108108
return requestJson(updateWorkspaceCredentialContract, {
109109
params: { id: credentialId },
110110
body,
111-
query: { workspaceId: 'workspaceId' in input ? input.workspaceId : undefined },
111+
query: { workspaceId },
112112
})
113113
},
114114
onSuccess: reconcile,

apps/sim/lib/api/contracts/organization-credentials.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,9 @@ export const createOrganizationCredentialDraftContract = defineRouteContract({
9090
},
9191
})
9292

93-
export const updateOrganizationCredentialBodySchema = z.intersection(
94-
updateCredentialByIdBodySchema,
95-
z.object({ organizationId: organizationIdSchema })
96-
)
93+
export const updateOrganizationCredentialBodySchema = updateCredentialByIdBodySchema.safeExtend({
94+
organizationId: organizationIdSchema,
95+
})
9796
export type UpdateOrganizationCredentialBody = z.input<
9897
typeof updateOrganizationCredentialBodySchema
9998
>

0 commit comments

Comments
 (0)