@@ -21,8 +21,9 @@ import {
2121import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
2222import { sha256Hex } from '@sim/security/hash'
2323import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
24+ import { createDeferred } from '@sim/testing/helpers/deferred'
2425import { generateId } from '@sim/utils/id'
25- import { eq , inArray } from 'drizzle-orm'
26+ import { eq , inArray , sql } from 'drizzle-orm'
2627import { afterAll , afterEach , beforeAll , beforeEach , describe , expect , it , vi } from 'vitest'
2728import { env } from '@/lib/core/config/env'
2829import { closeRedisConnection } from '@/lib/core/config/redis'
@@ -31,13 +32,21 @@ import {
3132 completeSlackCredentialGroupConfiguration ,
3233 startSlackCredentialGroupConfiguration ,
3334} from '@/lib/credential-groups/application/slack-managed-users'
34- import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration'
35+ import { lockOrganizationSlackApp } from '@/lib/credential-groups/organization-slack-app'
36+ import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter'
37+ import {
38+ decryptCredentialGroupProviderConfiguration ,
39+ emptyCredentialGroupProviderConfiguration ,
40+ encryptCredentialGroupProviderConfiguration ,
41+ } from '@/lib/credential-groups/provider-configuration'
3542import { getCredentialGroup } from '@/lib/credential-groups/service'
3643import { SLACK_SEARCH_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes'
3744import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider'
3845import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts'
3946import { encryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth'
47+ import type { DbOrTx } from '@/lib/db/types'
4048import { approveSearchIntegration } from '@/lib/knowledge/application/search-integrations'
49+ import { listSlackSearchInstallations } from '@/lib/knowledge/application/slack-search/installations'
4150import { revokeSlackSearchAccess } from '@/lib/knowledge/application/slack-search/lifecycle'
4251import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema'
4352import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env'
@@ -48,6 +57,7 @@ vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
4857} ) )
4958
5059const SHARED_APP_ID = 'ASHAREDFIXTURE'
60+ const CUSTOM_APP_ID = 'ACUSTOMFIXTURE'
5161const MEMBER_TEAM = 'TMEMBERS'
5262const OTHER_TEAM = 'TOTHER'
5363
@@ -143,6 +153,48 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
143153 input : { state, code : 'fixture-code' } ,
144154 } )
145155
156+ async function installBot ( appId : string , teamId : string , executor : DbOrTx = db ) {
157+ const secret = ( await encryptSecret ( 'fixture-bot' ) ) . encrypted
158+ await executor
159+ . insert ( slackApp )
160+ . values (
161+ appId === SHARED_APP_ID
162+ ? { id : appId , kind : 'shared' , organizationId : null , revision : generateId ( ) }
163+ : {
164+ id : appId ,
165+ kind : 'custom' ,
166+ organizationId : ids . organization ,
167+ clientId : 'fixture-custom-client' ,
168+ encryptedClientSecret : secret ,
169+ encryptedSigningSecret : secret ,
170+ revision : generateId ( ) ,
171+ }
172+ )
173+ . onConflictDoNothing ( )
174+ const botId = generateId ( )
175+ await executor . insert ( credential ) . values ( {
176+ id : botId ,
177+ organizationId : ids . organization ,
178+ type : 'service_account' ,
179+ providerId : 'slack' ,
180+ displayName : 'Fixture Slack bot' ,
181+ createdBy : ids . owner ,
182+ encryptedServiceAccountKey : secret ,
183+ } )
184+ await executor . insert ( slackSearchInstallation ) . values ( {
185+ id : generateId ( ) ,
186+ organizationId : ids . organization ,
187+ credentialId : botId ,
188+ appId,
189+ slackAppId : appId ,
190+ teamId,
191+ teamName : 'Fixture team' ,
192+ botUserId : 'UBOT' ,
193+ credentialVersion : generateId ( ) ,
194+ revision : generateId ( ) ,
195+ } )
196+ }
197+
146198 async function accountsGroup ( ) {
147199 const [ group ] = await db
148200 . select ( )
@@ -230,6 +282,16 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
230282 } )
231283 ) . resolves . toMatchObject ( { authorizationAppId : `slack:${ SHARED_APP_ID } :${ MEMBER_TEAM } ` } )
232284 await expect ( approveSlack ( ) ) . resolves . toMatchObject ( { memberAccounts : { changed : false } } )
285+ await expect (
286+ listSlackSearchInstallations . execute ( {
287+ principal : principal ( ) ,
288+ input : { organizationId : ids . organization } ,
289+ } )
290+ ) . resolves . toMatchObject ( {
291+ sharedAppAvailable : true ,
292+ memberAppKind : 'shared' ,
293+ installations : [ ] ,
294+ } )
233295
234296 const second = new URL ( ( await startSetup ( groupId ) ) . authorizationUrl )
235297 expect ( second . searchParams . get ( 'team' ) ) . toBe ( MEMBER_TEAM )
@@ -243,34 +305,99 @@ describe('Slack member accounts on the shared app without a Search bot', () => {
243305 it ( 'refuses shared-app member setup when the organization already has a Search bot' , async ( ) => {
244306 const groupId = ( await approveSlack ( ) ) . memberAccounts ?. groupId
245307 if ( ! groupId ) throw new Error ( 'Approval did not provision organization accounts' )
246- await db
247- . insert ( slackApp )
248- . values ( { id : SHARED_APP_ID , kind : 'shared' , organizationId : null , revision : generateId ( ) } )
249- const botId = generateId ( )
250- await db . insert ( credential ) . values ( {
251- id : botId ,
252- organizationId : ids . organization ,
253- type : 'service_account' ,
254- providerId : 'slack' ,
255- displayName : 'Fixture Slack bot' ,
256- createdBy : ids . owner ,
257- encryptedServiceAccountKey : ( await encryptSecret ( 'fixture-bot' ) ) . encrypted ,
258- } )
259- await db . insert ( slackSearchInstallation ) . values ( {
260- id : generateId ( ) ,
261- organizationId : ids . organization ,
262- credentialId : botId ,
263- appId : SHARED_APP_ID ,
264- slackAppId : SHARED_APP_ID ,
265- teamId : MEMBER_TEAM ,
266- teamName : 'Fixture team' ,
267- botUserId : 'UBOT' ,
268- credentialVersion : generateId ( ) ,
269- revision : generateId ( ) ,
270- } )
308+ await installBot ( SHARED_APP_ID , MEMBER_TEAM )
271309 await expect ( startSetup ( groupId ) ) . rejects . toThrow ( 'installed Slack app' )
272310 } )
273311
312+ it ( 'refuses to finish member setup when a custom bot is installed during authorization' , async ( ) => {
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+ const before = await accountsGroup ( )
317+ await installBot ( CUSTOM_APP_ID , OTHER_TEAM )
318+ provideSlackConsent ( MEMBER_TEAM )
319+ await expect ( completeSetup ( pending . searchParams . get ( 'state' ) ?? '' ) ) . rejects . toThrow (
320+ 'installed during authorization'
321+ )
322+ expect ( await accountsGroup ( ) ) . toEqual ( before )
323+ } )
324+
325+ it ( 'waits for an in-flight bot install before finishing member setup' , async ( ) => {
326+ const groupId = ( await approveSlack ( ) ) . memberAccounts ?. groupId
327+ if ( ! groupId ) throw new Error ( 'Approval did not provision organization accounts' )
328+ const pending = new URL ( ( await startSetup ( groupId ) ) . authorizationUrl )
329+ const before = await accountsGroup ( )
330+ provideSlackConsent ( MEMBER_TEAM )
331+ const locked = createDeferred < number > ( )
332+ const release = createDeferred < void > ( )
333+ const install = db . transaction ( async ( tx ) => {
334+ await lockOrganizationSlackApp ( tx , ids . organization )
335+ await installBot ( CUSTOM_APP_ID , OTHER_TEAM , tx )
336+ const [ connection ] = await tx . execute < { pid : number } > ( sql `SELECT pg_backend_pid() AS pid` )
337+ locked . resolve ( connection . pid )
338+ await release . promise
339+ } )
340+ const installPid = await locked . promise
341+ const completion = completeSetup ( pending . searchParams . get ( 'state' ) ?? '' )
342+ completion . catch ( ( ) => undefined )
343+ try {
344+ await vi . waitFor (
345+ async ( ) => {
346+ const [ waiting ] = await db . execute < { pid : number } > (
347+ sql `SELECT pid FROM pg_stat_activity WHERE ${ installPid } = ANY(pg_blocking_pids(pid))`
348+ )
349+ expect ( waiting ) . toBeDefined ( )
350+ } ,
351+ { timeout : 5_000 }
352+ )
353+ } finally {
354+ release . resolve ( )
355+ await install
356+ }
357+ await expect ( completion ) . rejects . toThrow ( 'installed during authorization' )
358+ expect ( await accountsGroup ( ) ) . toEqual ( before )
359+ } )
360+
361+ it ( 'reports a custom-app member setup and keeps it off the shared-app path' , async ( ) => {
362+ const groupId = ( await approveSlack ( ) ) . memberAccounts ?. groupId
363+ if ( ! groupId ) throw new Error ( 'Approval did not provision organization accounts' )
364+ const scopes = [ ...SLACK_SEARCH_USER_SCOPES ]
365+ await db
366+ . update ( credentialGroup )
367+ . set ( {
368+ options : [
369+ {
370+ id : generateId ( ) ,
371+ provider : 'slack' ,
372+ label : 'Slack' ,
373+ authorizationAppId : `slack:${ CUSTOM_APP_ID } :${ OTHER_TEAM } ` ,
374+ requiredScopes : scopes ,
375+ scopeVersion : credentialGroupScopePolicyVersion ( scopes ) ,
376+ required : false ,
377+ status : 'active' ,
378+ } ,
379+ ] ,
380+ encryptedProviderConfiguration : await encryptCredentialGroupProviderConfiguration ( {
381+ ...emptyCredentialGroupProviderConfiguration ( ) ,
382+ slack : {
383+ source : 'slack_app' ,
384+ appId : CUSTOM_APP_ID ,
385+ teamId : OTHER_TEAM ,
386+ scopes,
387+ verifiedAt : new Date ( ) . toISOString ( ) ,
388+ } ,
389+ } ) ,
390+ } )
391+ . where ( eq ( credentialGroup . id , groupId ) )
392+ await expect (
393+ listSlackSearchInstallations . execute ( {
394+ principal : principal ( ) ,
395+ input : { organizationId : ids . organization } ,
396+ } )
397+ ) . resolves . toMatchObject ( { memberAppKind : 'custom' , installations : [ ] } )
398+ await expect ( startSetup ( groupId ) ) . rejects . toThrow ( 'Remove the existing Slack setup' )
399+ } )
400+
274401 it ( 'revokes shared-app member grants in every organization when a workspace has no bot' , async ( ) => {
275402 const groupId = ( await approveSlack ( ) ) . memberAccounts ?. groupId
276403 if ( ! groupId ) throw new Error ( 'Approval did not provision organization accounts' )
0 commit comments