@@ -14,6 +14,9 @@ const m = vi.hoisted(() => ({
1414 audit : vi . fn ( ) ,
1515 baseUrl : vi . fn ( ) ,
1616 shared : vi . fn ( ) ,
17+ revoke : vi . fn ( ) ,
18+ validateGrant : vi . fn ( ) ,
19+ ensureGroup : vi . fn ( ) ,
1720} ) )
1821vi . mock ( '@/lib/slack-search/shared-app' , ( ) => ( { readSharedSlackSearchApp : m . shared } ) )
1922vi . mock ( '@sim/audit' , ( ) => ( {
@@ -45,7 +48,12 @@ vi.mock('@/lib/slack-search/oauth-state', () => ({
4548 consumeSlackSearchOAuthAttempt : m . consume ,
4649 storeSlackSearchOAuthAttempt : m . store ,
4750} ) )
48- vi . mock ( '@/lib/internal/slack/oauth' , ( ) => ( { exchangeSlackBotAuthorization : m . exchange } ) )
51+ vi . mock ( '@/lib/internal/slack/oauth' , ( ) => ( {
52+ exchangeSlackBotAuthorization : m . exchange ,
53+ revokeSlackBotAuthorization : m . revoke ,
54+ validateSlackBotAuthorization : m . validateGrant ,
55+ } ) )
56+ vi . mock ( '@/lib/credential-groups/service' , ( ) => ( { ensureWorkspaceAccountsGroup : m . ensureGroup } ) )
4957vi . mock ( '@/lib/credential-groups/organization-slack-app' , ( ) => ( {
5058 loadOrganizationSlackMemberApps : async ( ) => [ ] ,
5159 adoptOrganizationSlackMemberApp : vi . fn ( ) ,
@@ -87,6 +95,9 @@ const complete = () =>
8795beforeEach ( ( ) => {
8896 vi . clearAllMocks ( )
8997 m . shared . mockResolvedValue ( null )
98+ m . revoke . mockResolvedValue ( undefined )
99+ m . validateGrant . mockReset ( )
100+ m . ensureGroup . mockResolvedValue ( { id : 'accounts' } )
90101 m . baseUrl . mockReturnValue ( 'https://sim.test' )
91102 m . membership . mockResolvedValue ( [ { role : 'admin' } ] )
92103 m . rows . mockReset ( ) . mockResolvedValue ( [ ] )
@@ -286,3 +297,130 @@ it('rejects a shared-app callback if the global configuration was disabled or ro
286297 await expect ( complete ( ) ) . rejects . toThrow ( )
287298 expect ( m . exchange ) . not . toHaveBeenCalled ( )
288299} )
300+
301+ describe ( 'shared app completion' , ( ) => {
302+ const sharedApp = { id : 'A1' , revision : 'shared-revision' , kind : 'shared' , organizationId : null }
303+ beforeEach ( ( ) => {
304+ m . shared . mockResolvedValue ( sharedApp )
305+ m . consume . mockResolvedValue ( {
306+ ...attempt ,
307+ sharedApp : { id : sharedApp . id , revision : sharedApp . revision } ,
308+ } )
309+ } )
310+
311+ it ( 'commits the personal app configuration, bot credential and installation in one transaction' , async ( ) => {
312+ m . rows
313+ . mockResolvedValueOnce ( [ sharedApp ] )
314+ . mockResolvedValueOnce ( [ ] )
315+ . mockResolvedValueOnce ( [ ] )
316+ . mockResolvedValueOnce ( [
317+ { id : 'accounts' , options : [ ] , encryptedProviderConfiguration : null } ,
318+ ] )
319+ await expect ( complete ( ) ) . resolves . toEqual ( { organizationId : 'org1' } )
320+ expect ( db . transaction ) . toHaveBeenCalledOnce ( )
321+ expect ( m . ensureGroup ) . toHaveBeenCalledWith (
322+ { kind : 'organization' , organizationId : 'org1' } ,
323+ 'admin' ,
324+ undefined ,
325+ expect . objectContaining ( { insert : expect . any ( Function ) } )
326+ )
327+ const group = m . set . mock . calls [ 0 ] [ 0 ]
328+ expect ( group . options ) . toEqual ( [
329+ expect . objectContaining ( {
330+ provider : 'slack' ,
331+ authorizationAppId : 'slack:A1:T1' ,
332+ status : 'active' ,
333+ requiredScopes : expect . arrayContaining ( [
334+ 'channels:history' ,
335+ 'groups:history' ,
336+ 'im:history' ,
337+ 'mpim:history' ,
338+ 'users:read.email' ,
339+ ] ) ,
340+ } ) ,
341+ ] )
342+ const configuration = JSON . parse (
343+ group . encryptedProviderConfiguration . slice ( 'encrypted:' . length )
344+ )
345+ expect ( configuration . slack ) . toMatchObject ( {
346+ source : 'slack_app' ,
347+ appId : 'A1' ,
348+ teamId : 'T1' ,
349+ scopes : group . options [ 0 ] . requiredScopes ,
350+ } )
351+ expect ( configuration . slack ) . not . toHaveProperty ( 'clientSecret' )
352+ const rows = m . values . mock . calls . map ( ( [ value ] ) => value )
353+ expect ( rows ) . toHaveLength ( 2 )
354+ expect ( rows [ 0 ] ) . toMatchObject ( {
355+ organizationId : 'org1' ,
356+ workspaceId : null ,
357+ type : 'service_account' ,
358+ slackAppId : 'A1' ,
359+ } )
360+ expect ( rows [ 1 ] ) . toMatchObject ( {
361+ organizationId : 'org1' ,
362+ credentialId : rows [ 0 ] . id ,
363+ slackAppId : 'A1' ,
364+ appId : 'A1' ,
365+ teamId : 'T1' ,
366+ enabled : true ,
367+ } )
368+ expect ( m . verify ) . toHaveBeenCalledTimes ( 2 )
369+ expect ( m . revoke ) . not . toHaveBeenCalled ( )
370+ expect ( m . audit ) . toHaveBeenCalledOnce ( )
371+ } )
372+
373+ it ( 'revokes an unused shared bot grant after a conflicting workspace binding' , async ( ) => {
374+ m . rows
375+ . mockResolvedValueOnce ( [ sharedApp ] )
376+ . mockResolvedValueOnce ( [ ] )
377+ . mockResolvedValueOnce ( [ { id : 'other-app' } ] )
378+ await expect ( complete ( ) ) . rejects . toThrow ( 'already has an active Search installation' )
379+ expect ( m . revoke ) . toHaveBeenCalledWith ( 'bot-token' )
380+ expect ( m . values ) . not . toHaveBeenCalled ( )
381+ expect ( m . audit ) . not . toHaveBeenCalled ( )
382+ } )
383+
384+ it ( 'revokes an unused shared grant after a database write fails' , async ( ) => {
385+ m . rows
386+ . mockResolvedValueOnce ( [ sharedApp ] )
387+ . mockResolvedValueOnce ( [ ] )
388+ . mockResolvedValueOnce ( [ ] )
389+ . mockResolvedValueOnce ( [
390+ { id : 'accounts' , options : [ ] , encryptedProviderConfiguration : null } ,
391+ ] )
392+ m . values . mockImplementationOnce ( ( ) => {
393+ throw new Error ( 'write failed' )
394+ } )
395+ await expect ( complete ( ) ) . rejects . toThrow ( 'write failed' )
396+ expect ( m . revoke ) . toHaveBeenCalledWith ( 'bot-token' )
397+ expect ( m . audit ) . not . toHaveBeenCalled ( )
398+ } )
399+
400+ it ( 'never revokes a bot with an existing installation when the initiating admin loses access' , async ( ) => {
401+ m . verify . mockImplementationOnce ( async ( ) => {
402+ m . membership . mockResolvedValue ( [ { role : 'member' } ] )
403+ return identity
404+ } )
405+ m . rows . mockResolvedValueOnce ( [ { id : 'existing-installation' } ] )
406+ await expect ( complete ( ) ) . rejects . toThrow ( 'administrator' )
407+ expect ( m . revoke ) . not . toHaveBeenCalled ( )
408+ expect ( m . values ) . not . toHaveBeenCalled ( )
409+ } )
410+
411+ it ( 'revokes a shared grant rejected by scope or token-rotation policy' , async ( ) => {
412+ m . validateGrant . mockImplementationOnce ( ( ) => {
413+ throw new Error ( 'unsupported grant' )
414+ } )
415+ await expect ( complete ( ) ) . rejects . toThrow ( 'unsupported grant' )
416+ expect ( m . revoke ) . toHaveBeenCalledWith ( 'bot-token' )
417+ expect ( m . values ) . not . toHaveBeenCalled ( )
418+ } )
419+
420+ it ( 'surfaces cleanup failure with a concrete recovery step' , async ( ) => {
421+ m . verify . mockRejectedValueOnce ( new Error ( 'invalid bot' ) )
422+ m . revoke . mockRejectedValueOnce ( new Error ( 'provider failed' ) )
423+ await expect ( complete ( ) ) . rejects . toThrow ( 'Remove the unused app in Slack before retrying' )
424+ expect ( m . audit ) . not . toHaveBeenCalled ( )
425+ } )
426+ } )
0 commit comments