Skip to content

Commit 475d679

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(slack): paginate selector resources
1 parent 9d8d595 commit 475d679

3 files changed

Lines changed: 614 additions & 137 deletions

File tree

apps/sim/lib/selectors/manifest.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ export const selectorManifest = {
205205
'zoom.meetings': providerSelector([], { listMode: 'paginated', detail: true }),
206206
'slack.channels': providerSelector([], {
207207
sourceFields: { oauthCredential: ['botToken'] },
208+
listMode: 'paginated',
209+
detail: true,
208210
}),
209211
'snowflake.databases': providerSelector(['database', 'schema'], {
210212
detail: true,
@@ -240,6 +242,8 @@ export const selectorManifest = {
240242
}),
241243
'slack.users': providerSelector([], {
242244
sourceFields: { oauthCredential: ['botToken'] },
245+
listMode: 'paginated',
246+
detail: true,
243247
}),
244248
'outlook.folders': providerSelector([], { listMode: 'paginated', detail: true }),
245249
'outlook.calendars': providerSelector([], { listMode: 'paginated', detail: true }),

apps/sim/lib/selectors/server/providers/slack.test.ts

Lines changed: 212 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,37 +22,90 @@ import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-
2222
import { slackSelectorAttachments } from '@/lib/selectors/server/providers/slack'
2323
import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types'
2424

25-
function channelArgs(signal?: AbortSignal): ExecuteServerSelectorArgs {
25+
const SCOPED_ACCOUNT_ID = 'slack-usr_U12345678-123e4567-e89b-12d3-a456-426614174000'
26+
27+
function args(
28+
selectorKey: 'slack.channels' | 'slack.users',
29+
request: ExecuteServerSelectorArgs['request'] = { kind: 'list' },
30+
authentication: 'bot' | 'oauth' = 'bot',
31+
signal?: AbortSignal
32+
): ExecuteServerSelectorArgs {
2633
return {
27-
selectorKey: 'slack.channels',
34+
selectorKey,
2835
context: { oauthCredential: 'credential-1' },
29-
request: { kind: 'list' },
36+
request,
3037
scope: { kind: 'workspace', workspaceId: 'workspace-1' },
3138
workspaceId: 'workspace-1',
3239
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
3340
requesterUserId: 'user-1',
34-
credential: { suppliedId: 'credential-1', fixedToken: 'xoxb-server-only-token' },
41+
credential:
42+
authentication === 'bot'
43+
? { suppliedId: 'credential-1', fixedToken: 'xoxb-server-only-token' }
44+
: {
45+
suppliedId: 'credential-1',
46+
access: {
47+
ok: true,
48+
credentialOwnerUserId: 'owner-1',
49+
resolvedCredentialId: 'credential-1',
50+
credentialType: 'oauth',
51+
},
52+
},
3553
references: new Map(),
3654
protectedValues: createSelectorProtectedValues(),
3755
signal,
3856
}
3957
}
4058

59+
function queueScopedAccount(): void {
60+
queueTableRows(account, [{ accountId: SCOPED_ACCOUNT_ID }])
61+
}
62+
63+
function requestedUrl(call: number): URL {
64+
return new URL(String(mockFetchProviderJson.mock.calls[call]?.[0]))
65+
}
66+
67+
function channel(id: string, name: string, isPrivate = false, isMember?: boolean) {
68+
return { id, name, is_private: isPrivate, ...(isMember ? { is_member: true } : {}) }
69+
}
70+
71+
function user(id: string, name: string, realName?: string) {
72+
return { id, name, ...(realName ? { real_name: realName } : {}) }
73+
}
74+
75+
function slackPage<T extends Record<string, unknown>>(body: T, nextCursor?: string) {
76+
return {
77+
ok: true,
78+
...body,
79+
...(nextCursor ? { response_metadata: { next_cursor: nextCursor } } : {}),
80+
}
81+
}
82+
83+
function execute(
84+
selectorKey: 'slack.channels' | 'slack.users',
85+
request: ExecuteServerSelectorArgs['request'] = { kind: 'list' },
86+
authentication: 'bot' | 'oauth' = 'bot',
87+
signal?: AbortSignal
88+
) {
89+
return slackSelectorAttachments[selectorKey].execute(
90+
args(selectorKey, request, authentication, signal)
91+
)
92+
}
93+
4194
describe('Slack server selector adapters', () => {
4295
beforeEach(() => {
4396
vi.clearAllMocks()
4497
resetDbChainMock()
4598
mockResolveSelectorOAuthAccessToken.mockResolvedValue('xoxb-server-only-token')
4699
})
47100

48-
it('uses the bounded provider reader and does not fall back after caller cancellation', async () => {
101+
it('does not fall back after channel listing is cancelled', async () => {
49102
const controller = new AbortController()
50103
const abortError = new DOMException('The operation was aborted', 'AbortError')
51104
controller.abort(abortError)
52105
mockFetchProviderJson.mockRejectedValue(abortError)
53106

54107
await expect(
55-
slackSelectorAttachments['slack.channels'].execute(channelArgs(controller.signal))
108+
execute('slack.channels', { kind: 'list' }, 'bot', controller.signal)
56109
).rejects.toBe(abortError)
57110

58111
expect(mockFetchProviderJson).toHaveBeenCalledOnce()
@@ -62,30 +115,165 @@ describe('Slack server selector adapters', () => {
62115
it('does not return a public-only fallback when membership lookup is cancelled', async () => {
63116
const controller = new AbortController()
64117
const abortError = new DOMException('The operation was aborted', 'AbortError')
65-
queueTableRows(account, [
66-
{ accountId: 'slack-usr_U12345678-123e4567-e89b-12d3-a456-426614174000' },
67-
])
118+
queueScopedAccount()
68119
mockFetchProviderJson
69-
.mockResolvedValueOnce({
70-
ok: true,
71-
channels: [{ id: 'C123', name: 'general', is_private: false }],
72-
})
120+
.mockResolvedValueOnce(slackPage({ channels: [channel('C123', 'general')] }))
73121
.mockImplementationOnce(async () => {
74122
controller.abort(abortError)
75123
throw abortError
76124
})
77-
const args = channelArgs(controller.signal)
78-
args.credential = {
79-
suppliedId: 'credential-1',
80-
access: {
81-
ok: true,
82-
credentialOwnerUserId: 'owner-1',
83-
resolvedCredentialId: 'credential-1',
84-
credentialType: 'oauth',
85-
},
86-
}
87125

88-
await expect(slackSelectorAttachments['slack.channels'].execute(args)).rejects.toBe(abortError)
126+
await expect(
127+
execute('slack.channels', { kind: 'list' }, 'oauth', controller.signal)
128+
).rejects.toBe(abortError)
89129
expect(mockFetchProviderJson).toHaveBeenCalledTimes(2)
90130
})
131+
132+
it('continues a short users page only when its returned cursor is requested', async () => {
133+
mockFetchProviderJson
134+
.mockResolvedValueOnce(
135+
slackPage({ members: [user('U001', 'first', 'First User')] }, 'users-page-2')
136+
)
137+
.mockResolvedValueOnce(slackPage({ members: [user('U002', 'second', 'Second User')] }))
138+
139+
const first = await execute('slack.users')
140+
expect(first).toMatchObject({
141+
kind: 'list',
142+
items: [{ id: 'U001', label: 'First User' }],
143+
nextCursor: expect.any(String),
144+
})
145+
if (first.kind !== 'list' || !first.nextCursor) throw new Error('Expected a users cursor')
146+
147+
await expect(
148+
execute('slack.users', { kind: 'list', cursor: first.nextCursor })
149+
).resolves.toEqual({
150+
kind: 'list',
151+
items: [{ id: 'U002', label: 'Second User' }],
152+
})
153+
expect(requestedUrl(0).searchParams.has('cursor')).toBe(false)
154+
expect(requestedUrl(1).searchParams.get('cursor')).toBe('users-page-2')
155+
})
156+
157+
it('continues public and installing-user private channel streams independently', async () => {
158+
queueScopedAccount()
159+
queueScopedAccount()
160+
mockFetchProviderJson
161+
.mockResolvedValueOnce(
162+
slackPage(
163+
{
164+
channels: [channel('C001', 'general'), channel('G001', 'bot-only', true, true)],
165+
},
166+
'conversations-page-2'
167+
)
168+
)
169+
.mockResolvedValueOnce(slackPage({ channels: [] }, 'memberships-page-2'))
170+
.mockResolvedValueOnce(slackPage({ channels: [channel('C002', 'announcements')] }))
171+
.mockResolvedValueOnce(
172+
slackPage({ channels: [channel('G002', 'installing-user-private', true)] })
173+
)
174+
175+
const first = await execute('slack.channels', { kind: 'list' }, 'oauth')
176+
expect(first).toMatchObject({
177+
kind: 'list',
178+
items: [{ id: 'C001', label: '#general' }],
179+
nextCursor: expect.any(String),
180+
})
181+
if (first.kind !== 'list' || !first.nextCursor) throw new Error('Expected a channels cursor')
182+
183+
await expect(
184+
execute('slack.channels', { kind: 'list', cursor: first.nextCursor }, 'oauth')
185+
).resolves.toEqual({
186+
kind: 'list',
187+
items: [
188+
{ id: 'C002', label: '#announcements' },
189+
{ id: 'G002', label: '#installing-user-private' },
190+
],
191+
})
192+
expect(requestedUrl(2).searchParams.get('cursor')).toBe('conversations-page-2')
193+
expect(requestedUrl(3).searchParams.get('cursor')).toBe('memberships-page-2')
194+
})
195+
196+
it('fails closed for private list and detail results when membership cannot be verified', async () => {
197+
queueScopedAccount()
198+
queueScopedAccount()
199+
mockFetchProviderJson
200+
.mockResolvedValueOnce(
201+
slackPage({
202+
channels: [channel('C001', 'general'), channel('G001', 'private', true, true)],
203+
})
204+
)
205+
.mockRejectedValueOnce(new Error('membership lookup failed'))
206+
.mockResolvedValueOnce(slackPage({ channel: channel('G001', 'private', true, true) }))
207+
.mockRejectedValueOnce(new Error('member list failed'))
208+
209+
await expect(execute('slack.channels', { kind: 'list' }, 'oauth')).resolves.toEqual({
210+
kind: 'list',
211+
items: [{ id: 'C001', label: '#general' }],
212+
})
213+
await expect(
214+
execute('slack.channels', { kind: 'detail', id: 'G001' }, 'oauth')
215+
).resolves.toEqual({ kind: 'detail', item: null })
216+
})
217+
218+
it('preserves bot-only fallback without allowing its cursor under scoped OAuth', async () => {
219+
mockFetchProviderJson
220+
.mockRejectedValueOnce(new Error('private scope unavailable'))
221+
.mockResolvedValueOnce(slackPage({ channels: [channel('C001', 'general')] }, 'public-page-2'))
222+
223+
const botResult = await execute('slack.channels')
224+
expect(botResult).toMatchObject({
225+
kind: 'list',
226+
items: [{ id: 'C001', label: '#general' }],
227+
nextCursor: expect.any(String),
228+
})
229+
expect(requestedUrl(0).searchParams.get('types')).toBe('public_channel,private_channel')
230+
expect(requestedUrl(1).searchParams.get('types')).toBe('public_channel')
231+
if (botResult.kind !== 'list' || !botResult.nextCursor) {
232+
throw new Error('Expected a public-only bot cursor')
233+
}
234+
235+
queueTableRows(account, [])
236+
mockFetchProviderJson.mockRejectedValueOnce(new Error('OAuth list failed'))
237+
await expect(execute('slack.channels', { kind: 'list' }, 'oauth')).rejects.toMatchObject({
238+
name: 'SelectorOptionsUnavailableError',
239+
})
240+
241+
queueScopedAccount()
242+
await expect(
243+
execute('slack.channels', { kind: 'list', cursor: botResult.nextCursor }, 'oauth')
244+
).rejects.toMatchObject({ name: 'SelectorContextUnavailableError' })
245+
expect(mockFetchProviderJson).toHaveBeenCalledTimes(3)
246+
})
247+
248+
it('hydrates saved users and installing-user private channels directly by id', async () => {
249+
mockFetchProviderJson.mockResolvedValueOnce(
250+
slackPage({ user: user('U999', 'saved', 'Saved User') })
251+
)
252+
await expect(execute('slack.users', { kind: 'detail', id: 'U999' })).resolves.toEqual({
253+
kind: 'detail',
254+
item: { id: 'U999', label: 'Saved User' },
255+
})
256+
257+
queueScopedAccount()
258+
mockFetchProviderJson
259+
.mockResolvedValueOnce(slackPage({ channel: channel('G999', 'saved-private', true, true) }))
260+
.mockResolvedValueOnce(slackPage({ members: ['UOTHER'] }, 'members-page-2'))
261+
.mockResolvedValueOnce(slackPage({ members: ['U12345678'] }))
262+
await expect(
263+
execute('slack.channels', { kind: 'detail', id: 'G999' }, 'oauth')
264+
).resolves.toEqual({
265+
kind: 'detail',
266+
item: { id: 'G999', label: '#saved-private' },
267+
})
268+
269+
expect(
270+
mockFetchProviderJson.mock.calls.map((_, index) => requestedUrl(index).pathname)
271+
).toEqual([
272+
'/api/users.info',
273+
'/api/conversations.info',
274+
'/api/conversations.members',
275+
'/api/conversations.members',
276+
])
277+
expect(requestedUrl(3).searchParams.get('cursor')).toBe('members-page-2')
278+
})
91279
})

0 commit comments

Comments
 (0)