@@ -22,37 +22,90 @@ import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-
2222import { slackSelectorAttachments } from '@/lib/selectors/server/providers/slack'
2323import 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+
4194describe ( '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