@@ -72,20 +72,27 @@ import {
7272 seedKnowledgeAclFixture ,
7373 seedKnowledgeMemberFixture ,
7474} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
75+ import { GITHUB_READ_SOURCE_TIMEOUT_MS } from '@/lib/knowledge/access/github-installation'
76+ import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope'
7577import { subjectToken } from '@/lib/knowledge/access/tokens'
7678import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors'
7779import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks'
7880import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
81+ import { readIndexedKnowledgeDocument } from '@/lib/knowledge/application/read-indexed-document'
7982import { searchKnowledge } from '@/lib/knowledge/application/search'
83+ import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview'
8084import { listSearchSources } from '@/lib/knowledge/application/search-sources'
8185import { grantKnowledgeConnectorCredentialAccess } from '@/lib/knowledge/connectors/member-access'
8286import { executeMemberSync } from '@/lib/knowledge/connectors/member-sync-engine'
8387import {
8488 MEMBER_SUSPENDED_PURGE_DAYS ,
8589 MEMBER_TOMBSTONE_PURGE_DAYS ,
8690} from '@/lib/knowledge/connectors/sync-limits'
91+ import { getDocuments } from '@/lib/knowledge/documents/service'
92+ import { getTagUsageStats } from '@/lib/knowledge/tags/service'
8793import { deleteFile } from '@/lib/uploads/core/storage-service'
8894import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
95+ import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
8996
9097const redisUrl = process . env . KNOWLEDGE_ACL_TEST_REDIS_URL
9198if ( redisUrl ) {
@@ -105,6 +112,7 @@ interface RepositoryFixture {
105112 id : number
106113 public : boolean
107114 installed : boolean
115+ stallRef : boolean
108116 readers : Set < string >
109117 defaultBranch : string
110118 files : Map < string , string >
@@ -133,6 +141,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
133141 const { privateKey, publicKey } = generateKeyPairSync ( 'rsa' , { modulusLength : 2048 } )
134142 let organizationSource = false
135143 let installationSuspended = false
144+ let referenceObserved : ( ( repository : string ) => void ) | undefined
136145 const installation = ( ) => ( {
137146 id : 42 ,
138147 app_id : 1 ,
@@ -164,6 +173,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
164173 id : 9001 + repositories . size ,
165174 public : false ,
166175 installed : true ,
176+ stallRef : false ,
167177 readers : new Set ( readers ) ,
168178 defaultBranch : 'trunk' ,
169179 files : new Map ( [
@@ -240,30 +250,33 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
240250 if ( url . pathname === '/app/installations/42/access_tokens' ) {
241251 expect ( request . method ) . toBe ( 'POST' )
242252 const body = await request . json ( )
243- expect ( body ) . toEqual ( {
253+ expect ( body ) . toMatchObject ( {
244254 permissions : { contents : 'read' , metadata : 'read' } ,
245- repository_ids : [ 9001 ] ,
246255 } )
256+ expect ( body . repository_ids ) . toHaveLength ( 1 )
257+ const repositoryId = body . repository_ids [ 0 ]
258+ expect (
259+ [ ...repositories . values ( ) ] . some (
260+ ( repository ) => repository . id === repositoryId && repository . installed
261+ )
262+ ) . toBe ( true )
247263 return Response . json ( {
248- token : 'ghs_fixture_installation' ,
264+ token : `ghs_fixture_installation_ ${ repositoryId } ` ,
249265 expires_at : new Date ( Date . now ( ) + 60 * 60_000 ) . toISOString ( ) ,
250266 permissions : body . permissions ,
251- repositories : [ { id : 9001 } ] ,
267+ repositories : [ { id : repositoryId } ] ,
252268 } )
253269 }
254270 expect ( request . method ) . toBe ( 'GET' )
255- if (
256- url . pathname === '/repos/fixture/shared/installation' &&
257- ! repositories . get ( 'shared' ) ?. installed
258- )
271+ const repositoryInstallation = url . pathname . match ( / ^ \/ r e p o s \/ f i x t u r e \/ ( [ ^ / ] + ) \/ i n s t a l l a t i o n $ / )
272+ if ( repositoryInstallation && ! repositories . get ( repositoryInstallation [ 1 ] ) ?. installed )
259273 return Response . json ( { message : 'Not Found' } , { status : 404 } )
260- expect ( [ '/app/installations/42' , '/repos/fixture/shared/installation' ] ) . toContain (
261- url . pathname
262- )
274+ expect ( url . pathname === '/app/installations/42' || Boolean ( repositoryInstallation ) ) . toBe ( true )
263275 return Response . json ( installation ( ) )
264276 }
265277 if ( request . method !== 'GET' ) throw new Error ( `Unexpected GitHub method: ${ request . method } ` )
266- const installationToken = bearer === 'ghs_fixture_installation'
278+ const installationRepository = bearer . match ( / ^ g h s _ f i x t u r e _ i n s t a l l a t i o n _ ( \d + ) $ / ) ?. [ 1 ]
279+ const installationToken = Boolean ( installationRepository )
267280 const member = enrolled . members . find ( ( candidate ) =>
268281 [ tokenFor ( candidate . userId ) , `${ tokenFor ( candidate . userId ) } _refreshed` ] . some (
269282 ( token ) => request . headers . get ( 'authorization' ) === `Bearer ${ token } `
@@ -295,6 +308,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
295308 if ( ! match ) throw new Error ( `Unexpected GitHub endpoint: ${ url . pathname } ` )
296309 const source = repositories . get ( match [ 1 ] )
297310 if ( ! source ) throw new Error ( 'Unexpected GitHub repository' )
311+ if ( installationToken ) expect ( installationRepository ) . toBe ( String ( source . id ) )
298312 if ( source . throttledReaders . has ( actingId ) )
299313 return Response . json (
300314 { message : 'You have exceeded a secondary rate limit.' } ,
@@ -317,6 +331,13 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
317331 default_branch : source . defaultBranch ,
318332 } )
319333 if ( match [ 2 ] . startsWith ( '/git/ref/heads/' ) ) {
334+ referenceObserved ?.( match [ 1 ] )
335+ if ( source . stallRef )
336+ return new Promise < Response > ( ( _resolve , reject ) => {
337+ request . signal . addEventListener ( 'abort' , ( ) => reject ( request . signal . reason ) , {
338+ once : true ,
339+ } )
340+ } )
320341 const ref = decodeURIComponent ( match [ 2 ] . slice ( '/git/ref/heads/' . length ) )
321342 return ref === source . defaultBranch
322343 ? Response . json ( { ref : `refs/heads/${ ref } ` , object : { type : 'commit' , sha : shaFor ( ref ) } } )
@@ -399,6 +420,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
399420 refreshedUsers . clear ( )
400421 organizationSource = false
401422 installationSuspended = false
423+ referenceObserved = undefined
402424 oauthStateKey = undefined
403425 oauthVerification = undefined
404426 Object . assign ( env , {
@@ -585,7 +607,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
585607 . orderBy ( document . externalId )
586608 }
587609
588- async function search ( principal : Principal ) {
610+ async function search ( principal : Principal , searchMode : 'hybrid' | 'vector' = 'hybrid' ) {
589611 const result = await searchKnowledge . execute ( {
590612 principal,
591613 input : {
@@ -594,7 +616,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
594616 : { workspaceId : ids . workspaceId } ) ,
595617 knowledgeBaseIds : [ ids . knowledgeBaseId ] ,
596618 query : 'Orion' ,
597- searchMode : 'hybrid' ,
619+ searchMode,
598620 topK : 20 ,
599621 } ,
600622 } )
@@ -697,11 +719,79 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
697719 actorUserId : ids . aliceId ,
698720 organizationId : ids . organizationId ,
699721 } )
722+ const unrelatedSources = Array . from ( { length : 105 } , ( ) => generateId ( ) )
723+ await db . insert ( knowledgeConnector ) . values (
724+ unrelatedSources . map ( ( id ) => ( {
725+ id,
726+ knowledgeBaseId : ids . knowledgeBaseId ,
727+ connectorType : 'github' ,
728+ accessMode : 'members' ,
729+ credentialId : installationCredentialId ,
730+ credentialGroupId : enrolled . groupId ,
731+ credentialGroupOptionId : enrolled . optionId ,
732+ sourceConfig : { repository : 'fixture/shared' , githubRepositoryId : '9001' } ,
733+ } ) )
734+ )
735+ await db . insert ( knowledgeConnectorMember ) . values (
736+ unrelatedSources . map ( ( connectorId ) => ( {
737+ id : generateId ( ) ,
738+ organizationId : ids . organizationId ,
739+ connectorId,
740+ credentialId : enrolled . members [ 0 ] . credentialId ,
741+ subjectToken : enrolled . members [ 0 ] . subjectToken ,
742+ } ) )
743+ )
700744 const result = await sync ( )
701745 expect ( result . error ) . toBeUndefined ( )
702746 expect ( result . docsHydratedOnce ) . toBe ( 1 )
703747 const [ indexed ] = await rows ( )
704748 expect ( indexed ) . toBeDefined ( )
749+ const provider = ( userId : string ) =>
750+ createKnowledgeAccessProvider ( actor ( userId ) , {
751+ organizationId : ids . organizationId ,
752+ knowledgeBaseIds : [ ids . knowledgeBaseId ] ,
753+ } )
754+ const page = ( userId : string , offset = 0 ) =>
755+ getDocuments (
756+ ids . knowledgeBaseId ,
757+ { limit : 1 , offset, sortBy : 'filename' , sortOrder : 'asc' } ,
758+ 'github-candidate-regression' ,
759+ provider ( userId )
760+ )
761+ expect ( await page ( ids . aliceId ) ) . toMatchObject ( {
762+ documents : [ { id : indexed . id } ] ,
763+ pagination : { total : 1 } ,
764+ } )
765+ expect (
766+ (
767+ await readSearchSourceOverview . execute ( {
768+ principal : actor ( ids . aliceId ) ,
769+ input : { organizationId : ids . organizationId } ,
770+ } )
771+ ) . hasSearchableDocuments
772+ ) . toBe ( true )
773+ await db . update ( document ) . set ( { tag1 : 'fixture' } ) . where ( eq ( document . id , indexed . id ) )
774+ await db . update ( embedding ) . set ( { tag1 : 'fixture' } ) . where ( eq ( embedding . documentId , indexed . id ) )
775+ expect (
776+ await getTagUsageStats ( ids . knowledgeBaseId , provider ( ids . aliceId ) , 'github-tag-regression' )
777+ ) . toEqual (
778+ expect . arrayContaining ( [
779+ expect . objectContaining ( { tagSlot : 'tag1' , documentCount : 1 , chunkCount : 1 } ) ,
780+ ] )
781+ )
782+ expect (
783+ (
784+ await readIndexedKnowledgeDocument . execute ( {
785+ principal : actor ( ids . aliceId ) ,
786+ input : {
787+ organizationId : ids . organizationId ,
788+ target : { kind : 'url' , url : indexed . sourceUrl ! } ,
789+ limit : 1 ,
790+ resultSecretRegistry : new ResolvedSecretTraceRegistry ( ) ,
791+ } ,
792+ } )
793+ ) . documentId
794+ ) . toBe ( indexed . id )
705795 expect (
706796 requests . filter ( ( entry ) => entry . path . includes ( '/git/blobs/' ) ) . map ( ( entry ) => entry . userId )
707797 ) . toEqual ( [ 'installation' ] )
@@ -710,6 +800,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
710800 await assertAccess ( actor ( ids . bobId ) , indexed , true )
711801 const source = repositories . get ( 'shared' ) !
712802 source . readers . delete ( ids . bobId )
803+ expect ( await page ( ids . bobId ) ) . toMatchObject ( { documents : [ ] , pagination : { total : 0 } } )
713804 expect ( await search ( actor ( ids . bobId ) ) ) . toEqual ( [ ] )
714805 await assertAccess ( actor ( ids . bobId ) , indexed , false )
715806 expect ( await search ( actor ( ids . aliceId ) ) ) . toEqual ( [ indexed . id ] )
@@ -729,6 +820,77 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
729820 expect ( await search ( actor ( ids . aliceId ) ) ) . toEqual ( [ ] )
730821 installationSuspended = false
731822 expect ( await search ( actor ( ids . aliceId ) ) ) . toEqual ( [ indexed . id ] )
823+ const slowRepository = repository ( 'slow' )
824+ const slowSourceId = generateId ( )
825+ await db . insert ( knowledgeConnector ) . values ( {
826+ id : slowSourceId ,
827+ knowledgeBaseId : ids . knowledgeBaseId ,
828+ connectorType : 'github' ,
829+ accessMode : 'members' ,
830+ credentialId : installationCredentialId ,
831+ credentialGroupId : enrolled . groupId ,
832+ credentialGroupOptionId : enrolled . optionId ,
833+ sourceConfig : { repository : 'fixture/slow' , githubRepositoryId : String ( slowRepository . id ) } ,
834+ } )
835+ expect ( ( await sync ( slowSourceId ) ) . error ) . toBeUndefined ( )
836+ const [ slowDocument ] = await rows ( slowSourceId )
837+ expect ( slowDocument ) . toBeDefined ( )
838+ const deniedRepository = repository ( 'denied-paging' , [ ids . aliceId ] )
839+ const deniedSourceId = generateId ( )
840+ await db . insert ( knowledgeConnector ) . values ( {
841+ id : deniedSourceId ,
842+ knowledgeBaseId : ids . knowledgeBaseId ,
843+ connectorType : 'github' ,
844+ accessMode : 'members' ,
845+ credentialId : installationCredentialId ,
846+ credentialGroupId : enrolled . groupId ,
847+ credentialGroupOptionId : enrolled . optionId ,
848+ sourceConfig : {
849+ repository : 'fixture/denied-paging' ,
850+ githubRepositoryId : String ( deniedRepository . id ) ,
851+ } ,
852+ } )
853+ expect ( ( await sync ( deniedSourceId ) ) . error ) . toBeUndefined ( )
854+ const [ deniedDocument ] = await rows ( deniedSourceId )
855+ deniedRepository . readers . delete ( ids . aliceId )
856+ for ( const [ id , filename ] of [
857+ [ indexed . id , 'alpha' ] ,
858+ [ deniedDocument . id , 'beta' ] ,
859+ [ slowDocument . id , 'gamma' ] ,
860+ ] )
861+ await db . update ( document ) . set ( { filename } ) . where ( eq ( document . id , id ) )
862+ expect ( await page ( ids . aliceId , 1 ) ) . toMatchObject ( {
863+ documents : [ { id : slowDocument . id } ] ,
864+ pagination : { total : 2 , offset : 1 } ,
865+ } )
866+ slowRepository . stallRef = true
867+ const sourceTimers : AbortController [ ] = [ ]
868+ const nativeTimeout = AbortSignal . timeout . bind ( AbortSignal )
869+ const timerSpy = vi . spyOn ( AbortSignal , 'timeout' ) . mockImplementation ( ( duration ) => {
870+ if ( duration !== GITHUB_READ_SOURCE_TIMEOUT_MS ) return nativeTimeout ( duration )
871+ const controller = new AbortController ( )
872+ sourceTimers . push ( controller )
873+ return controller . signal
874+ } )
875+ try {
876+ const observed = new Set < string > ( )
877+ const candidatesStarted = new Promise < void > ( ( resolve ) => {
878+ referenceObserved = ( name ) => {
879+ observed . add ( name )
880+ if ( observed . has ( 'shared' ) && observed . has ( 'slow' ) ) resolve ( )
881+ }
882+ } )
883+ const pending = search ( actor ( ids . aliceId ) , 'vector' )
884+ await candidatesStarted
885+ /** Complete the fast response's microtasks before expiring the stalled candidate. */
886+ for ( let turn = 0 ; turn < 20 ; turn ++ ) await Promise . resolve ( )
887+ for ( const timer of sourceTimers ) timer . abort ( new Error ( 'fixture source timeout' ) )
888+ expect ( await pending ) . toEqual ( [ indexed . id ] )
889+ } finally {
890+ timerSpy . mockRestore ( )
891+ referenceObserved = undefined
892+ slowRepository . stallRef = false
893+ }
732894 await db
733895 . delete ( member )
734896 . where ( and ( eq ( member . organizationId , ids . organizationId ) , eq ( member . userId , ids . bobId ) ) )
0 commit comments