Skip to content

Commit 6895e69

Browse files
committed
fix(search): close authorization races and repair integration fixtures
1 parent dd51560 commit 6895e69

25 files changed

Lines changed: 924 additions & 257 deletions

apps/sim/app/api/v1/knowledge/search/route.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
mockExecuteKnowledgeSearch,
1717
mockGenerateSearchEmbedding,
1818
mockGetDocumentMetadataByIds,
19+
mockGetDocumentTagDefinitions,
1920
mockAuthenticateRequest,
2021
mockValidateWorkspaceAccess,
2122
mockResolveBillingAttribution,
@@ -26,6 +27,7 @@ const {
2627
mockExecuteKnowledgeSearch: vi.fn(),
2728
mockGenerateSearchEmbedding: vi.fn(),
2829
mockGetDocumentMetadataByIds: vi.fn(),
30+
mockGetDocumentTagDefinitions: vi.fn(),
2931
mockAuthenticateRequest: vi.fn(),
3032
mockValidateWorkspaceAccess: vi.fn(),
3133
mockResolveBillingAttribution: vi.fn(),
@@ -91,7 +93,7 @@ vi.mock('@/app/api/v1/knowledge/utils', () => ({
9193
}))
9294

9395
vi.mock('@/lib/knowledge/tags/service', () => ({
94-
getDocumentTagDefinitions: vi.fn().mockResolvedValue([]),
96+
getDocumentTagDefinitions: mockGetDocumentTagDefinitions,
9597
}))
9698

9799
import { POST } from '@/app/api/v1/knowledge/search/route'
@@ -124,6 +126,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => {
124126
})
125127
mockExecuteKnowledgeSearch.mockResolvedValue([])
126128
mockGetDocumentMetadataByIds.mockResolvedValue({})
129+
mockGetDocumentTagDefinitions.mockResolvedValue([])
127130
mockResolveBillingAttribution.mockImplementation(
128131
({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) =>
129132
Promise.resolve({
@@ -165,6 +168,82 @@ describe('v1 knowledge search route — per-KB embedding model', () => {
165168
expect(mockGetDocumentMetadataByIds).toHaveBeenCalledWith([], access, provider)
166169
})
167170

171+
it.each([
172+
['query', false],
173+
['query', true],
174+
['filters', false],
175+
['filters', true],
176+
] as const)(
177+
'omits newly denied content from %s results and counts when all denied is %s',
178+
async (mode, allDenied) => {
179+
const access = { kind: 'user' as const, userId: 'user-1', tokens: ['reader-token'] }
180+
const provider = {
181+
get: vi.fn().mockResolvedValue(access),
182+
getForConnectors: vi.fn(),
183+
getForDocuments: vi.fn(),
184+
}
185+
mockResolveV1KnowledgeReadAccess.mockResolvedValue(provider)
186+
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
187+
hasAccess: true,
188+
knowledgeBase: baseKb('kb-1', 'text-embedding-3-small'),
189+
})
190+
mockGetDocumentTagDefinitions.mockResolvedValue([
191+
{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
192+
])
193+
mockExecuteKnowledgeSearch.mockResolvedValue([
194+
{
195+
documentId: 'revoked-document',
196+
knowledgeBaseId: 'kb-1',
197+
content: 'revoked page content',
198+
tag1: 'revoked tag',
199+
chunkIndex: 0,
200+
distance: 0.1,
201+
},
202+
{
203+
documentId: 'allowed-document',
204+
knowledgeBaseId: 'kb-1',
205+
content: 'allowed page content',
206+
tag1: 'docs',
207+
chunkIndex: 0,
208+
distance: 0.2,
209+
},
210+
])
211+
mockGetDocumentMetadataByIds.mockResolvedValue(
212+
allDenied ? {} : { 'allowed-document': { filename: 'Allowed page', sourceUrl: null } }
213+
)
214+
const response = await POST(
215+
createMockRequest('POST', {
216+
workspaceId: 'ws-1',
217+
knowledgeBaseIds: 'kb-1',
218+
...(mode === 'query'
219+
? { query: 'docs' }
220+
: { tagFilters: [{ tagName: 'category', operator: 'eq', value: 'docs' }] }),
221+
})
222+
)
223+
const body = await response.json()
224+
expect(response.status).toBe(200)
225+
expect(mockGetDocumentMetadataByIds).toHaveBeenCalledWith(
226+
['revoked-document', 'allowed-document'],
227+
access,
228+
provider
229+
)
230+
expect(body.data.results).toEqual(
231+
allDenied
232+
? []
233+
: [
234+
expect.objectContaining({
235+
documentId: 'allowed-document',
236+
documentName: 'Allowed page',
237+
content: 'allowed page content',
238+
metadata: { category: 'docs' },
239+
}),
240+
]
241+
)
242+
expect(body.data.totalResults).toBe(allDenied ? 0 : 1)
243+
expect(JSON.stringify(body)).not.toContain('revoked')
244+
}
245+
)
246+
168247
it('passes the KB embedding model into generateSearchEmbedding', async () => {
169248
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
170249
hasAccess: true,

apps/sim/app/api/v1/knowledge/search/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,11 +312,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
312312

313313
const documentIds = results.map((r) => r.documentId)
314314
const documentMetadataMap = await getDocumentMetadataByIds(documentIds, access, accessProvider)
315+
const readableResults = results.filter((result) => documentMetadataMap[result.documentId])
315316

316317
return NextResponse.json({
317318
success: true,
318319
data: {
319-
results: results.map((result) => {
320+
results: readableResults.map((result) => {
320321
const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {}
321322
const tags: Record<string, string | number | boolean | Date | null> = {}
322323

@@ -342,7 +343,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
342343
query: query || '',
343344
knowledgeBaseIds: accessibleKbIds,
344345
topK,
345-
totalResults: results.length,
346+
totalResults: readableResults.length,
346347
},
347348
})
348349
} catch (error) {

apps/sim/hooks/use-member-enrollment.test.tsx

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,201 @@ describe('useMemberEnrollment', () => {
211211
)
212212
})
213213

214+
it.each([
215+
['existing', 'account_mismatch'],
216+
['existing', 'denied'],
217+
['existing', 'expired'],
218+
['new', 'account_mismatch'],
219+
['new', 'denied'],
220+
['new', 'expired'],
221+
] as const)(
222+
'ignores the previous %s source’s %s while its retry request is pending',
223+
(source, failure) => {
224+
mount(new Set(), true, mocks.connectionError)
225+
const mutation = source === 'existing' ? mocks.enrollmentMutate : mocks.sourceConnectionMutate
226+
const connect = () => {
227+
if (source === 'existing') enrollment().connect('kb-1', 'connector-1')
228+
else enrollment().connectSource('workspace-1', 'jira', { projectKey: 'ENG' })
229+
}
230+
act(connect)
231+
act(() =>
232+
mutation.mock.calls[0][1].onSuccess({
233+
url: 'https://provider.test/previous',
234+
connectorId: 'connector-1',
235+
})
236+
)
237+
act(() => vi.advanceTimersByTime(9 * 60_000))
238+
act(connect)
239+
if (failure !== 'expired') {
240+
act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: failure })))
241+
}
242+
act(() => vi.advanceTimersByTime(60_000))
243+
expect(mocks.connectionError).not.toHaveBeenCalled()
244+
expect(enrollment().error).toBeNull()
245+
act(() =>
246+
mutation.mock.calls[1][1].onSuccess({
247+
url: 'https://provider.test/retry',
248+
connectorId: 'connector-1',
249+
})
250+
)
251+
expect(enrollment().isAwaiting('connector-1')).toBe(true)
252+
expect(mocks.channels[1].close).not.toHaveBeenCalled()
253+
}
254+
)
255+
256+
it.each([
257+
['existing', 'success'],
258+
['existing', 'failure'],
259+
['new', 'success'],
260+
['new', 'failure'],
261+
] as const)('ignores a superseded %s source request’s late %s', (source, outcome) => {
262+
mount(new Set(), true, mocks.connectionError)
263+
const mutation = source === 'existing' ? mocks.enrollmentMutate : mocks.sourceConnectionMutate
264+
const retryTab = { location: { href: '' }, closed: false, close: vi.fn() }
265+
vi.mocked(window.open)
266+
.mockReturnValueOnce(enrollmentTab as unknown as Window)
267+
.mockReturnValueOnce(retryTab as unknown as Window)
268+
for (let index = 0; index < 2; index += 1) {
269+
act(() => {
270+
if (source === 'existing') enrollment().connect('kb-1', 'connector-1')
271+
else enrollment().connectSource('workspace-1', 'jira', { projectKey: 'ENG' })
272+
})
273+
}
274+
act(() =>
275+
mutation.mock.calls[1][1].onSuccess({
276+
url: 'https://provider.test/retry',
277+
connectorId: 'connector-1',
278+
})
279+
)
280+
act(() => {
281+
if (outcome === 'failure') {
282+
mutation.mock.calls[0][1].onError(new Error('Previous request failed'))
283+
} else {
284+
mutation.mock.calls[0][1].onSuccess({
285+
url: 'https://provider.test/previous',
286+
connectorId: 'connector-1',
287+
})
288+
}
289+
})
290+
expect(enrollmentTab.location.href).toBe('')
291+
expect(retryTab.location.href).toBe('https://provider.test/retry')
292+
expect(retryTab.close).not.toHaveBeenCalled()
293+
expect(enrollment().isAwaiting('connector-1')).toBe(true)
294+
expect(mocks.channels[1].close).not.toHaveBeenCalled()
295+
expect(mocks.connectionError).not.toHaveBeenCalled()
296+
expect(enrollment().error).toBeNull()
297+
})
298+
299+
it('retires a first-source authorization when retrying its resolved connector', () => {
300+
mount(new Set(), true, mocks.connectionError)
301+
act(() => enrollment().connectSource('workspace-1', 'jira', { projectKey: 'ENG' }))
302+
act(() =>
303+
mocks.sourceConnectionMutate.mock.calls[0][1].onSuccess({
304+
url: 'https://provider.test/previous',
305+
connectorId: 'connector-1',
306+
})
307+
)
308+
act(() => enrollment().connect('kb-1', 'connector-1'))
309+
act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'denied' })))
310+
expect(mocks.channels[0].close).toHaveBeenCalledOnce()
311+
expect(mocks.connectionError).not.toHaveBeenCalled()
312+
})
313+
314+
it('does not let a delayed first-source response replace its newer connector authorization', () => {
315+
mount(new Set(), true, mocks.connectionError)
316+
act(() => enrollment().connectSource('workspace-1', 'jira', { projectKey: 'ENG' }))
317+
act(() => enrollment().connect('kb-1', 'connector-1'))
318+
act(() =>
319+
mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ url: 'https://provider.test/retry' })
320+
)
321+
act(() =>
322+
mocks.sourceConnectionMutate.mock.calls[0][1].onSuccess({
323+
url: 'https://provider.test/previous',
324+
connectorId: 'connector-1',
325+
})
326+
)
327+
expect(enrollmentTab.location.href).toBe('https://provider.test/retry')
328+
expect(mocks.channels[1].close).not.toHaveBeenCalled()
329+
expect(enrollment().isAwaiting('connector-1')).toBe(true)
330+
})
331+
332+
it('ignores first-source success while a newer request for its connector is still pending', () => {
333+
mount(new Set(), true, mocks.connectionError)
334+
const retryTab = { location: { href: '' }, closed: false, close: vi.fn() }
335+
vi.mocked(window.open)
336+
.mockReturnValueOnce(enrollmentTab as unknown as Window)
337+
.mockReturnValueOnce(retryTab as unknown as Window)
338+
act(() => enrollment().connectSource('workspace-1', 'jira', { projectKey: 'ENG' }))
339+
act(() => enrollment().connect('kb-1', 'connector-1'))
340+
act(() =>
341+
mocks.sourceConnectionMutate.mock.calls[0][1].onSuccess({
342+
url: 'https://provider.test/previous',
343+
connectorId: 'connector-1',
344+
})
345+
)
346+
expect(enrollmentTab.location.href).toBe('')
347+
expect(enrollment().isAwaiting('connector-1')).toBe(false)
348+
act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'denied' })))
349+
expect(mocks.connectionError).not.toHaveBeenCalled()
350+
act(() =>
351+
mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ url: 'https://provider.test/retry' })
352+
)
353+
expect(retryTab.location.href).toBe('https://provider.test/retry')
354+
expect(enrollment().isAwaiting('connector-1')).toBe(true)
355+
expect(mocks.channels[1].close).not.toHaveBeenCalled()
356+
})
357+
358+
it('retires a pending connector request when a newer first-source request resolves to it', () => {
359+
mount(new Set(), true, mocks.connectionError)
360+
act(() => enrollment().connect('kb-1', 'connector-1'))
361+
act(() => enrollment().connectSource('workspace-1', 'jira', { projectKey: 'ENG' }))
362+
act(() =>
363+
mocks.sourceConnectionMutate.mock.calls[0][1].onSuccess({
364+
url: 'https://provider.test/retry',
365+
connectorId: 'connector-1',
366+
})
367+
)
368+
act(() => mocks.enrollmentMutate.mock.calls[0][1].onError(new Error('Previous request failed')))
369+
expect(mocks.connectionError).not.toHaveBeenCalled()
370+
expect(enrollment().isAwaiting('connector-1')).toBe(true)
371+
expect(mocks.channels[0].close).toHaveBeenCalledOnce()
372+
expect(mocks.channels[1].close).not.toHaveBeenCalled()
373+
})
374+
375+
it('keeps the previous authorization active when the retry popup is blocked', () => {
376+
mount(new Set(), true, mocks.connectionError)
377+
act(() => enrollment().connect('kb-1', 'connector-1'))
378+
act(() =>
379+
mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ url: 'https://provider.test/previous' })
380+
)
381+
vi.mocked(window.open).mockReturnValueOnce(null)
382+
act(() => enrollment().connect('kb-1', 'connector-1'))
383+
expect(mocks.channels[0].close).not.toHaveBeenCalled()
384+
expect(enrollment().isAwaiting('connector-1')).toBe(true)
385+
expect(mocks.enrollmentMutate).toHaveBeenCalledOnce()
386+
})
387+
388+
it('keeps pending source requests with different scopes or configurations independent', () => {
389+
mount(new Set(), true, mocks.connectionError)
390+
for (const [owner, projectKey] of [
391+
['workspace-1', 'ENG'],
392+
['workspace-1', 'SUPPORT'],
393+
['workspace-2', 'ENG'],
394+
]) {
395+
act(() => enrollment().connectSource(owner, 'jira', { projectKey }))
396+
}
397+
for (let index = 0; index < 3; index += 1) {
398+
act(() =>
399+
mocks.sourceConnectionMutate.mock.calls[index][1].onSuccess({
400+
url: `https://provider.test/attempt-${index}`,
401+
connectorId: `connector-${index}`,
402+
})
403+
)
404+
expect(enrollment().isAwaiting(`connector-${index}`)).toBe(true)
405+
expect(mocks.channels[index].close).not.toHaveBeenCalled()
406+
}
407+
})
408+
214409
it('reports a blocked popup once without starting a connection', () => {
215410
mount(new Set(), true, mocks.connectionError)
216411
vi.mocked(window.open).mockReturnValueOnce(null)

0 commit comments

Comments
 (0)