@@ -50,10 +50,12 @@ import {
5050 resolveManagedOAuthToken ,
5151} from '@/lib/credentials/managed-oauth'
5252import { acquireAdvisoryXactLock , tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
53+ import { deleteKnowledgeConnector } from '@/lib/knowledge/application/connectors'
5354import {
5455 approveSearchIntegration ,
5556 listSearchIntegrations ,
5657} from '@/lib/knowledge/application/search-integrations'
58+ import { deleteKnowledgeBase , restoreKnowledgeBase } from '@/lib/knowledge/service'
5759import {
5860 GITHUB_INSTALLATION_PROVIDER_ID ,
5961 type GitHubInstallationBinding ,
@@ -332,6 +334,191 @@ describe('atomic organization live Search MCP setup', () => {
332334 restoreSlackHttp = ( ) => spy . mockRestore ( )
333335 }
334336
337+ async function seedImplicitSlackApproval ( ) {
338+ const knowledgeBaseId = generateId ( )
339+ const connectorId = generateId ( )
340+ await db . insert ( knowledgeBase ) . values ( {
341+ id : knowledgeBaseId ,
342+ userId : ids . owner ,
343+ organizationId : ids . organization ,
344+ isSearchIndex : true ,
345+ name : 'Slack Search fixture' ,
346+ } )
347+ await db . insert ( knowledgeConnector ) . values ( {
348+ id : connectorId ,
349+ knowledgeBaseId,
350+ connectorType : 'slack' ,
351+ status : 'active' ,
352+ sourceConfig : { } ,
353+ } )
354+ return connectorId
355+ }
356+
357+ it . each ( [ false , true ] ) (
358+ 'verifies implicitly approved Search permissions unless explicitly disabled (disabled: %s)' ,
359+ async ( disabled ) => {
360+ const setup = await seedSlackAuthorization ( )
361+ await seedImplicitSlackApproval ( )
362+ if ( disabled )
363+ await approveSearchIntegration . execute ( {
364+ principal : createSessionPrincipal ( { userId : ids . owner , sessionId : generateId ( ) } ) ,
365+ input : { organizationId : ids . organization , connectorType : 'slack' , approved : false } ,
366+ } )
367+ expect ( await integrationStatus ( 'slack' ) ) . toMatchObject ( { approved : ! disabled } )
368+ const pending = await setup . start ( )
369+ const scopes = disabled
370+ ? [ ...SLACK_MANAGED_USER_SCOPES ]
371+ : [ ...new Set ( [ ...SLACK_MANAGED_USER_SCOPES , ...SLACK_SEARCH_USER_SCOPES ] ) ]
372+ expect ( new URL ( pending . authorizationUrl ) . searchParams . get ( 'user_scope' ) ! . split ( ',' ) ) . toEqual (
373+ expect . arrayContaining ( scopes )
374+ )
375+ if ( disabled )
376+ expect ( new URL ( pending . authorizationUrl ) . searchParams . get ( 'user_scope' ) ) . not . toContain (
377+ 'search:read.public'
378+ )
379+ provideSlackConsent ( scopes )
380+ await expect ( setup . complete ( pending . state ) ) . resolves . toMatchObject ( { ok : true } )
381+ const state = await snapshot ( )
382+ expect (
383+ state . groups [ 0 ] . options . find ( ( entry ) => entry . id === setup . optionId ) ?. requiredScopes
384+ ) . toEqual ( expect . arrayContaining ( scopes ) )
385+ if ( disabled )
386+ await expect ( setup . resolveToken ( ) ) . resolves . toMatchObject ( { accessToken : 'fixture-token' } )
387+ }
388+ )
389+
390+ it . each ( [ 'added' , 'removed' ] as const ) (
391+ 'rejects pending authorization when implicit Search approval is %s' ,
392+ async ( change ) => {
393+ const setup = await seedSlackAuthorization ( )
394+ const connectorId = change === 'removed' ? await seedImplicitSlackApproval ( ) : null
395+ const pending = await setup . start ( )
396+ if ( connectorId )
397+ await db
398+ . update ( knowledgeConnector )
399+ . set ( { archivedAt : new Date ( ) } )
400+ . where ( eq ( knowledgeConnector . id , connectorId ) )
401+ else await seedImplicitSlackApproval ( )
402+ provideSlackConsent ( [ ...SLACK_MANAGED_USER_SCOPES , ...SLACK_SEARCH_USER_SCOPES ] )
403+ await expect ( setup . complete ( pending . state ) ) . rejects . toThrow ( 'Search approval changed' )
404+ expect ( ( await snapshot ( ) ) . groups ) . toEqual ( setup . before . groups )
405+ await expect ( setup . resolveToken ( ) ) . resolves . toMatchObject ( { accessToken : 'fixture-token' } )
406+ }
407+ )
408+
409+ it ( 'stops granting implicit Search approval when a connector is removed with documents kept' , async ( ) => {
410+ const setup = await seedSlackAuthorization ( )
411+ const connectorId = await seedImplicitSlackApproval ( )
412+ await deleteKnowledgeConnector . execute ( {
413+ principal : createSessionPrincipal ( { userId : ids . owner , sessionId : generateId ( ) } ) ,
414+ input : { connectorId, assertedOrganizationId : ids . organization , deleteDocuments : false } ,
415+ } )
416+ expect ( await integrationStatus ( 'slack' ) ) . toMatchObject ( { approved : false } )
417+ const pending = await setup . start ( )
418+ expect ( new URL ( pending . authorizationUrl ) . searchParams . get ( 'user_scope' ) ) . not . toContain (
419+ 'search:read.public'
420+ )
421+ await setup . complete ( pending . state , 'access_denied' )
422+ await expect ( setup . resolveToken ( ) ) . resolves . toMatchObject ( { accessToken : 'fixture-token' } )
423+ } )
424+
425+ it . each ( [ 'remove connector' , 'archive index' , 'disable approval' , 'restore index' ] as const ) (
426+ 'serializes the Slack consent commit with Search lifecycle changes: %s' ,
427+ async ( change ) => {
428+ const setup = await seedSlackAuthorization ( )
429+ const connectorId = await seedImplicitSlackApproval ( )
430+ const [ index ] = await db
431+ . select ( )
432+ . from ( knowledgeBase )
433+ . where ( eq ( knowledgeBase . organizationId , ids . organization ) )
434+ if ( change === 'restore index' )
435+ await deleteKnowledgeBase ( index . id , generateId ( ) , { allowSearchIndexDelete : true } )
436+ const pending = await setup . start ( )
437+ provideSlackConsent ( [ ...SLACK_MANAGED_USER_SCOPES , ...SLACK_SEARCH_USER_SCOPES ] )
438+ const probe = `slack_consent_${ generateId ( ) . replace ( / - / g, '' ) } `
439+ const lockKey = `slack-consent-fixture:${ setup . groupId } `
440+ await db . $client . unsafe ( `CREATE FUNCTION ${ probe } () RETURNS trigger LANGUAGE plpgsql AS $$
441+ BEGIN
442+ PERFORM pg_advisory_xact_lock(hashtextextended('${ lockKey } ', 0));
443+ RETURN NEW;
444+ END $$` )
445+ await db . $client . unsafe ( `CREATE TRIGGER ${ probe } BEFORE UPDATE ON credential_group
446+ FOR EACH ROW WHEN (OLD.id = '${ setup . groupId } ') EXECUTE FUNCTION ${ probe } ()` )
447+ const locked = createDeferred < number > ( )
448+ const release = createDeferred < void > ( )
449+ const blocker = db . transaction ( async ( tx ) => {
450+ await acquireAdvisoryXactLock ( tx , 'slack_consent_fixture' , lockKey )
451+ const [ connection ] = await tx . execute < { pid : number } > ( sql `SELECT pg_backend_pid() AS pid` )
452+ locked . resolve ( connection . pid )
453+ await release . promise
454+ } )
455+ const blockerPid = await locked . promise
456+ const callback = setup . complete ( pending . state ) . catch ( ( error : unknown ) => error )
457+ let mutation : Promise < unknown > | undefined
458+ try {
459+ let callbackPid : number | undefined
460+ await vi . waitFor (
461+ async ( ) => {
462+ const [ waiting ] = await db . execute < { pid : number } > ( sql `
463+ SELECT pid FROM pg_stat_activity WHERE ${ blockerPid } = ANY(pg_blocking_pids(pid))
464+ ` )
465+ expect ( waiting ) . toBeDefined ( )
466+ callbackPid = waiting ?. pid
467+ } ,
468+ { timeout : 5_000 }
469+ )
470+ mutation = (
471+ change === 'remove connector'
472+ ? deleteKnowledgeConnector . execute ( {
473+ principal : createSessionPrincipal ( { userId : ids . owner , sessionId : generateId ( ) } ) ,
474+ input : {
475+ connectorId,
476+ assertedOrganizationId : ids . organization ,
477+ deleteDocuments : false ,
478+ } ,
479+ } )
480+ : change === 'archive index'
481+ ? deleteKnowledgeBase ( index . id , generateId ( ) , { allowSearchIndexDelete : true } )
482+ : change === 'restore index'
483+ ? restoreKnowledgeBase ( index . id , generateId ( ) )
484+ : approveSearchIntegration . execute ( {
485+ principal : createSessionPrincipal ( {
486+ userId : ids . owner ,
487+ sessionId : generateId ( ) ,
488+ } ) ,
489+ input : {
490+ organizationId : ids . organization ,
491+ connectorType : 'slack' ,
492+ approved : false ,
493+ } ,
494+ } )
495+ ) . catch ( ( error : unknown ) => error )
496+ await vi . waitFor (
497+ async ( ) => {
498+ const [ state ] = await db . execute < { waiting : boolean } > ( sql `
499+ SELECT EXISTS (SELECT 1 FROM pg_stat_activity
500+ WHERE ${ callbackPid ! } = ANY(pg_blocking_pids(pid))) AS waiting
501+ ` )
502+ expect ( state . waiting ) . toBe ( true )
503+ } ,
504+ { timeout : 3_000 }
505+ )
506+ } finally {
507+ release . resolve ( )
508+ await blocker
509+ const callbackResult = await callback
510+ const mutationResult = await mutation
511+ await db . $client . unsafe ( `DROP TRIGGER ${ probe } ON credential_group` )
512+ await db . $client . unsafe ( `DROP FUNCTION ${ probe } ()` )
513+ expect ( callbackResult ) . toMatchObject ( { ok : true } )
514+ expect ( mutationResult ) . not . toBeInstanceOf ( Error )
515+ }
516+ expect ( await integrationStatus ( 'slack' ) ) . toMatchObject ( {
517+ approved : change === 'restore index' ,
518+ } )
519+ }
520+ )
521+
335522 it . each ( [
336523 { name : 'workflow policy' , scopes : SLACK_MANAGED_USER_SCOPES } ,
337524 { name : 'custom policy' , scopes : [ 'chat:write' , 'users:read' , 'users:read.email' ] } ,
0 commit comments