@@ -47,11 +47,14 @@ import { encryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth'
4747import type { DbOrTx , DbTransaction } from '@/lib/db/types'
4848import { approveSearchIntegration } from '@/lib/knowledge/application/search-integrations'
4949import {
50+ configureSlackSearchInstallation ,
5051 listSlackSearchInstallations ,
5152 removeSlackSearchInstallation ,
5253} from '@/lib/knowledge/application/slack-search/installations'
5354import { revokeSlackSearchAccess } from '@/lib/knowledge/application/slack-search/lifecycle'
55+ import { SLACK_CUSTOM_BOT_PROVIDER_ID , SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types'
5456import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
57+ import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants'
5558import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env'
5659
5760vi . mock ( '@/lib/core/config/env-flags' , async ( importOriginal ) => ( {
@@ -156,8 +159,8 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
156159 input : { state, code : 'fixture-code' } ,
157160 } )
158161
159- async function installBot ( appId : string , teamId : string , executor : DbOrTx = db ) {
160- const secret = ( await encryptSecret ( 'fixture-bot ' ) ) . encrypted
162+ async function upsertSlackApp ( appId : string , executor : DbOrTx = db ) {
163+ const secret = ( await encryptSecret ( 'fixture-app ' ) ) . encrypted
161164 await executor
162165 . insert ( slackApp )
163166 . values (
@@ -174,6 +177,11 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
174177 }
175178 )
176179 . onConflictDoNothing ( )
180+ }
181+
182+ async function installBot ( appId : string , teamId : string , executor : DbOrTx = db ) {
183+ await upsertSlackApp ( appId , executor )
184+ const secret = ( await encryptSecret ( 'fixture-bot' ) ) . encrypted
177185 const botId = generateId ( )
178186 await executor . insert ( credential ) . values ( {
179187 id : botId ,
@@ -281,6 +289,99 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
281289 restoreSlackHttp = ( ) => spy . mockRestore ( )
282290 }
283291
292+ /** Answers bot verification as a bot of `appId` installed in `teamId`. */
293+ function provideSlackBot ( appId : string , teamId : string ) {
294+ restoreSlackHttp ?.( )
295+ const spy = vi . spyOn ( globalThis , 'fetch' ) . mockImplementation ( async ( input ) => {
296+ const url = new URL ( String ( input ) )
297+ switch ( `${ url . origin } ${ url . pathname } ` ) {
298+ case 'https://slack.com/api/auth.test' :
299+ return Response . json (
300+ { ok : true , team_id : teamId , team : 'Fixture team' , user_id : 'UBOT' , bot_id : 'BBOT' } ,
301+ { headers : { 'x-oauth-scopes' : SLACK_SHARED_SEARCH_BOT_SCOPES . join ( ',' ) } }
302+ )
303+ case 'https://slack.com/api/bots.info' :
304+ return Response . json ( { ok : true , bot : { id : 'BBOT' , app_id : appId } } )
305+ default :
306+ throw new Error ( `Unexpected Slack fixture request: ${ url } ` )
307+ }
308+ } )
309+ restoreSlackHttp = ( ) => spy . mockRestore ( )
310+ }
311+
312+ async function connectMembersOnSharedApp ( ) {
313+ const groupId = ( await approveSlack ( ) ) . memberAccounts ?. groupId
314+ if ( ! groupId ) throw new Error ( 'Approval did not provision organization accounts' )
315+ const pending = new URL ( ( await startSetup ( groupId ) ) . authorizationUrl )
316+ provideSlackConsent ( MEMBER_TEAM )
317+ await completeSetup ( pending . searchParams . get ( 'state' ) ?? '' )
318+ }
319+
320+ /** A bot credential left without a Search installation, as after removing its binding. */
321+ async function insertLeftoverBot ( appId : string , teamId : string ) {
322+ await upsertSlackApp ( appId )
323+ const { encrypted } = await encryptSecret (
324+ JSON . stringify ( {
325+ type : SLACK_CUSTOM_BOT_SECRET_TYPE ,
326+ botToken : 'xoxb-fixture' ,
327+ teamId,
328+ botUserId : 'UBOT' ,
329+ teamName : 'Fixture team' ,
330+ } )
331+ )
332+ const credentialId = generateId ( )
333+ await db . insert ( credential ) . values ( {
334+ id : credentialId ,
335+ organizationId : ids . organization ,
336+ type : 'service_account' ,
337+ providerId : SLACK_CUSTOM_BOT_PROVIDER_ID ,
338+ displayName : 'Leftover Slack bot' ,
339+ createdBy : ids . owner ,
340+ encryptedServiceAccountKey : encrypted ,
341+ slackAppId : appId ,
342+ } )
343+ return credentialId
344+ }
345+
346+ const enableBot = ( credentialId : string ) =>
347+ configureSlackSearchInstallation . execute ( {
348+ principal : principal ( ) ,
349+ input : { organizationId : ids . organization , credentialId, enabled : true } ,
350+ } )
351+
352+ const installations = ( ) =>
353+ db
354+ . select ( )
355+ . from ( slackSearchInstallation )
356+ . where ( eq ( slackSearchInstallation . organizationId , ids . organization ) )
357+
358+ it ( 'refuses to bind a leftover bot to a different app or workspace than members' , async ( ) => {
359+ await connectMembersOnSharedApp ( )
360+ const credentialId = await insertLeftoverBot ( CUSTOM_APP_ID , OTHER_TEAM )
361+ provideSlackBot ( CUSTOM_APP_ID , OTHER_TEAM )
362+ await expect ( enableBot ( credentialId ) ) . rejects . toMatchObject ( {
363+ code : 'conflict' ,
364+ message : 'Install the same Slack app and workspace connected by your members' ,
365+ } )
366+ expect ( await installations ( ) ) . toEqual ( [ ] )
367+ } )
368+
369+ it ( 'binds a leftover shared-app bot in the members workspace' , async ( ) => {
370+ await connectMembersOnSharedApp ( )
371+ const credentialId = await insertLeftoverBot ( SHARED_APP_ID , MEMBER_TEAM )
372+ provideSlackBot ( SHARED_APP_ID , MEMBER_TEAM )
373+ const created = await enableBot ( credentialId )
374+ expect ( await installations ( ) ) . toEqual ( [
375+ expect . objectContaining ( {
376+ id : created . id ,
377+ credentialId,
378+ appId : SHARED_APP_ID ,
379+ teamId : MEMBER_TEAM ,
380+ enabled : true ,
381+ } ) ,
382+ ] )
383+ } )
384+
284385 it ( 'approves Slack, verifies the workspace from the admin grant, and resolves member sign-in' , async ( ) => {
285386 const approval = await approveSlack ( )
286387 expect ( approval ) . toMatchObject ( { approved : true } )
0 commit comments