Skip to content

Commit 99218ad

Browse files
committed
test(knowledge): cover member access withdrawal on a lost provider scope
Real-Postgres integration suite for the member sync's scope-unavailable branch (member-sync-engine listForMember): a provider answer classified as a lost scope is an authoritative, complete, empty listing that withdraws only that member's observations on that connector, while a 503 or timeout keeps access. Asserts on document ACL rows, observation rows, and search visibility. Each document gets a unique one-hot embedding (index < 512) and is searched with its own vector, so visibility never depends on HNSW ties or ranking.
1 parent 2008383 commit 99218ad

1 file changed

Lines changed: 350 additions & 0 deletions

File tree

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
/**
2+
* Real PostgreSQL, member sync engine, document storage, indexing, ACL observations and search
3+
* authorization; only the provider's listing responses are fixtures. A lost scope is the error the
4+
* connectors raise through `listingRequestError` (Calendar on 404, Jira when no configured
5+
* project is reachable), classified by the shared `isListingScopeUnavailableError`.
6+
*/
7+
import { mkdtempSync } from 'node:fs'
8+
import { rm } from 'node:fs/promises'
9+
import { tmpdir } from 'node:os'
10+
import path from 'node:path'
11+
import { db } from '@sim/db'
12+
import {
13+
document,
14+
knowledgeConnector,
15+
knowledgeConnectorMember,
16+
knowledgeDocumentObservation,
17+
organization,
18+
resourcePolicy,
19+
user,
20+
workspace,
21+
} from '@sim/db/schema'
22+
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
23+
import { generateId } from '@sim/utils/id'
24+
import { eq, inArray } from 'drizzle-orm'
25+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
26+
27+
const fixture = vi.hoisted(() => ({
28+
storageRoot: '',
29+
list: vi.fn(),
30+
/** Document ids each source scope lists to a member who can reach it. */
31+
scopes: new Map<string, string[]>(),
32+
/** How the provider answers one member token on one scope instead of listing it. */
33+
failures: new Map<string, 'scope_lost' | 'unavailable' | 'timeout'>(),
34+
/**
35+
* A unique one-hot dimension (below 512, where candidate ranking truncates) per document, so a
36+
* query with a document's own vector finds exactly that document: orthogonal vectors sit at
37+
* cosine distance 1, outside the vector leg's threshold, and no ties reach the HNSW walk.
38+
*/
39+
slots: new Map<string, number>(),
40+
}))
41+
vi.mock('@/lib/uploads/core/setup.server', () => ({
42+
get UPLOAD_DIR_SERVER() {
43+
return fixture.storageRoot
44+
},
45+
}))
46+
vi.mock('@/lib/embeddings', async () => ({
47+
...(await import('@/lib/embeddings/client')),
48+
assertKnowledgeEmbeddingCapacity: async () => {},
49+
embedKnowledge: async (texts: string[]) => ({
50+
embeddings: texts.map((text) => {
51+
const slot = Number(/vector-slot-(\d+)/.exec(text)?.[1] ?? 511)
52+
return Array.from({ length: 1536 }, (_, index) => (index === slot ? 1 : 0))
53+
}),
54+
totalTokens: texts.length,
55+
billableTokens: 0,
56+
isBYOK: true,
57+
modelName: 'text-embedding-3-small',
58+
pricingId: 'text-embedding-3-small',
59+
}),
60+
}))
61+
vi.mock('@/connectors/registry.server', async () => {
62+
const { isListingScopeUnavailableError } = await import('@/connectors/utils')
63+
return {
64+
CONNECTOR_REGISTRY: {
65+
google_drive: {
66+
id: 'google_drive',
67+
name: 'Fixture Drive',
68+
auth: { mode: 'oauth', provider: 'google-drive' },
69+
permissionScopedListing: { capFieldIds: [] },
70+
listDocuments: fixture.list,
71+
getDocument: async () => {
72+
throw new Error('Unexpected document hydration')
73+
},
74+
isListingScopeUnavailableError,
75+
},
76+
},
77+
}
78+
})
79+
80+
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
81+
import { compileCredentialGroupWorkflowAccessPolicy } from '@/lib/credential-groups/application/workflow-access-policy'
82+
import {
83+
createKnowledgeAclFixtureIds,
84+
seedKnowledgeAclFixture,
85+
seedKnowledgeMemberFixture,
86+
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
87+
import { searchKnowledge } from '@/lib/knowledge/application/search'
88+
import * as memberAccess from '@/lib/knowledge/connectors/member-access'
89+
import { executeMemberSync } from '@/lib/knowledge/connectors/member-sync-engine'
90+
import type { ExternalDocument } from '@/connectors/types'
91+
import { listingRequestError } from '@/connectors/utils'
92+
93+
function sourceDoc(externalId: string): ExternalDocument {
94+
return {
95+
externalId,
96+
title: externalId,
97+
content: `${vectorQuery(externalId)}: the member listing decides who may read ${externalId}.`,
98+
mimeType: 'text/plain',
99+
contentHash: `hash-${externalId}`,
100+
}
101+
}
102+
103+
function vectorQuery(externalId: string) {
104+
let slot = fixture.slots.get(externalId)
105+
if (slot === undefined) {
106+
slot = fixture.slots.size + 1
107+
if (slot >= 511) throw new Error('Fixture ran out of unique embedding dimensions')
108+
fixture.slots.set(externalId, slot)
109+
}
110+
return `vector-slot-${slot}`
111+
}
112+
113+
const memberToken = (credentialId: string) => `member-${credentialId}`
114+
const answerKey = (scope: string, accessToken: string) => `${scope} ${accessToken}`
115+
116+
describe('member sync withdraws access when the provider reports a lost scope', () => {
117+
const ids = createKnowledgeAclFixtureIds()
118+
let billing: Awaited<ReturnType<typeof resolveBillingAttribution>>
119+
120+
beforeAll(async () => {
121+
fixture.storageRoot = mkdtempSync(path.join(tmpdir(), 'sim-member-scope-withdrawal-'))
122+
vi.stubGlobal('fetch', async () => {
123+
throw new Error('Unexpected outbound provider request')
124+
})
125+
fixture.list.mockImplementation(
126+
async (accessToken: string, sourceConfig: Record<string, unknown>) => {
127+
const scope = String(sourceConfig.scope)
128+
const failure = fixture.failures.get(answerKey(scope, accessToken))
129+
if (failure === 'scope_lost') throw listingRequestError('Failed to list source', 404)
130+
if (failure === 'unavailable') throw listingRequestError('Failed to list source', 503)
131+
if (failure === 'timeout')
132+
throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
133+
return { documents: (fixture.scopes.get(scope) ?? []).map(sourceDoc), hasMore: false }
134+
}
135+
)
136+
await seedKnowledgeAclFixture(ids)
137+
billing = await resolveBillingAttribution({
138+
actorUserId: ids.aliceId,
139+
workspaceId: ids.workspaceId,
140+
})
141+
vi.spyOn(memberAccess, 'mintKnowledgeConnectorMemberToken').mockImplementation(
142+
async ({ credentialId }) => ({ accessToken: memberToken(credentialId), refreshed: false })
143+
)
144+
})
145+
afterAll(async () => {
146+
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId))
147+
await db.delete(organization).where(eq(organization.id, ids.organizationId))
148+
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
149+
await rm(fixture.storageRoot, { recursive: true, force: true })
150+
await db.$client.end()
151+
vi.restoreAllMocks()
152+
vi.unstubAllGlobals()
153+
})
154+
155+
/** A members-mode connector over its own source scope, with Alice and Bob enrolled. */
156+
async function memberSource(scope: string, documentCount: number) {
157+
const seeded = await seedKnowledgeMemberFixture(ids)
158+
await db
159+
.insert(resourcePolicy)
160+
.values({
161+
id: generateId(),
162+
workspaceId: ids.workspaceId,
163+
resourceType: 'credential_group',
164+
resourceId: seeded.groupId,
165+
document: compileCredentialGroupWorkflowAccessPolicy({
166+
credentialGroupId: seeded.groupId,
167+
allowedWorkflowIds: [],
168+
}),
169+
createdBy: ids.aliceId,
170+
updatedBy: ids.aliceId,
171+
})
172+
.onConflictDoNothing()
173+
await memberAccess.grantKnowledgeConnectorCredentialAccess(
174+
{
175+
workspaceId: ids.workspaceId,
176+
credentialGroupId: seeded.groupId,
177+
credentialGroupOptionId: seeded.optionId,
178+
connectorId: seeded.connectorId,
179+
},
180+
ids.aliceId
181+
)
182+
await db
183+
.update(knowledgeConnector)
184+
.set({ memberSyncStatus: 'idle', memberSyncLockToken: null, sourceConfig: { scope } })
185+
.where(eq(knowledgeConnector.id, seeded.connectorId))
186+
fixture.scopes.set(
187+
scope,
188+
Array.from({ length: documentCount }, (_, index) => `${scope}-${index}`)
189+
)
190+
const [alice, bob] = seeded.members
191+
return { scope, connectorId: seeded.connectorId, alice, bob }
192+
}
193+
194+
/** Runs one member sync in which only `due` members are listed; the rest keep their schedule. */
195+
async function syncMembers(connectorId: string, due?: string[]) {
196+
if (due)
197+
await db
198+
.update(knowledgeConnectorMember)
199+
.set({ nextAttemptAt: new Date(0) })
200+
.where(inArray(knowledgeConnectorMember.id, due))
201+
const result = await executeMemberSync(connectorId, { billingAttribution: billing })
202+
expect(result.error).toBeUndefined()
203+
return result
204+
}
205+
206+
async function connectorDocuments(connectorId: string) {
207+
const rows = await db
208+
.select({
209+
id: document.id,
210+
externalId: document.externalId,
211+
acl: document.acl,
212+
processingStatus: document.processingStatus,
213+
})
214+
.from(document)
215+
.where(eq(document.connectorId, connectorId))
216+
expect(rows.length).toBeGreaterThan(0)
217+
expect(rows.every((row) => row.processingStatus === 'completed')).toBe(true)
218+
return rows
219+
}
220+
221+
/**
222+
* The ids of `docs` the user's search returns through the real access predicate, each searched
223+
* with its own vector so the answer never depends on ranking among other documents.
224+
*/
225+
async function searchableBy(userId: string, docs: { id: string; externalId: string | null }[]) {
226+
const found: string[] = []
227+
for (const doc of docs) {
228+
const { results } = await searchKnowledge.execute({
229+
principal: { kind: 'session', userId, sessionId: 'member-scope-withdrawal' },
230+
input: {
231+
workspaceId: ids.workspaceId,
232+
knowledgeBaseIds: [ids.knowledgeBaseId],
233+
query: vectorQuery(String(doc.externalId)),
234+
searchMode: 'vector',
235+
topK: 10,
236+
},
237+
})
238+
if (results.some((row) => row.documentId === doc.id)) found.push(doc.id)
239+
}
240+
return found.sort()
241+
}
242+
243+
async function memberRow(memberId: string) {
244+
const [row] = await db
245+
.select()
246+
.from(knowledgeConnectorMember)
247+
.where(eq(knowledgeConnectorMember.id, memberId))
248+
return row
249+
}
250+
251+
async function observationCount(memberId: string) {
252+
const rows = await db
253+
.select({ documentId: knowledgeDocumentObservation.documentId })
254+
.from(knowledgeDocumentObservation)
255+
.where(eq(knowledgeDocumentObservation.memberId, memberId))
256+
return rows.length
257+
}
258+
259+
it('withdraws the member whose provider reports the scope lost and leaves other members readable', async () => {
260+
const source = await memberSource('scope-lost', 3)
261+
await syncMembers(source.connectorId)
262+
const docs = await connectorDocuments(source.connectorId)
263+
const docIds = docs.map((row) => row.id).sort()
264+
expect(docIds).toHaveLength(3)
265+
for (const row of docs)
266+
expect(new Set(row.acl)).toEqual(
267+
new Set([source.alice.subjectToken, source.bob.subjectToken])
268+
)
269+
expect(await searchableBy(ids.aliceId, docs)).toEqual(docIds)
270+
expect(await searchableBy(ids.bobId, docs)).toEqual(docIds)
271+
expect((await memberRow(source.alice.id)).lastListedCount).toBe(3)
272+
273+
fixture.failures.set(
274+
answerKey(source.scope, memberToken(source.alice.credentialId)),
275+
'scope_lost'
276+
)
277+
const lost = await syncMembers(source.connectorId, [source.alice.id])
278+
expect(lost).toMatchObject({
279+
membersClaimed: 1,
280+
membersCompleted: 1,
281+
membersFailed: 0,
282+
observationsRemoved: 3,
283+
})
284+
285+
for (const row of await connectorDocuments(source.connectorId))
286+
expect(row.acl).toEqual([source.bob.subjectToken])
287+
expect(await observationCount(source.alice.id)).toBe(0)
288+
expect(await observationCount(source.bob.id)).toBe(3)
289+
expect(await searchableBy(ids.aliceId, docs)).toEqual([])
290+
expect(await searchableBy(ids.bobId, docs)).toEqual(docIds)
291+
expect(await memberRow(source.alice.id)).toMatchObject({
292+
status: 'active',
293+
lastError: null,
294+
lastListedCount: 0,
295+
})
296+
}, 60_000)
297+
298+
it.each([
299+
['a provider 503', 'unavailable', '503'],
300+
['a request timeout', 'timeout', 'timeout'],
301+
] as const)(
302+
'keeps the member readable when the listing fails with %s',
303+
async (_label, failure, errorText) => {
304+
const source = await memberSource(`transient-${failure}`, 3)
305+
await syncMembers(source.connectorId)
306+
const docs = await connectorDocuments(source.connectorId)
307+
const docIds = docs.map((row) => row.id).sort()
308+
expect(await searchableBy(ids.aliceId, docs)).toEqual(docIds)
309+
310+
fixture.failures.set(answerKey(source.scope, memberToken(source.alice.credentialId)), failure)
311+
const failed = await syncMembers(source.connectorId, [source.alice.id])
312+
expect(failed).toMatchObject({ membersClaimed: 1, membersFailed: 1, observationsRemoved: 0 })
313+
314+
const alice = await memberRow(source.alice.id)
315+
expect(alice.status).toBe('active')
316+
expect(alice.lastError).toContain(errorText)
317+
expect(await observationCount(source.alice.id)).toBe(3)
318+
for (const row of await connectorDocuments(source.connectorId))
319+
expect(row.acl).toContain(source.alice.subjectToken)
320+
expect(await searchableBy(ids.aliceId, docs)).toEqual(docIds)
321+
},
322+
60_000
323+
)
324+
325+
it("withdraws only the lost source's documents, not the member's access through another connector", async () => {
326+
const lostSource = await memberSource('withdrawn-source', 3)
327+
const keptSource = await memberSource('kept-source', 2)
328+
await syncMembers(lostSource.connectorId)
329+
await syncMembers(keptSource.connectorId)
330+
const lostDocs = await connectorDocuments(lostSource.connectorId)
331+
const keptDocs = await connectorDocuments(keptSource.connectorId)
332+
const lostIds = lostDocs.map((row) => row.id).sort()
333+
const keptIds = keptDocs.map((row) => row.id).sort()
334+
expect(await searchableBy(ids.aliceId, lostDocs)).toEqual(lostIds)
335+
expect(await searchableBy(ids.aliceId, keptDocs)).toEqual(keptIds)
336+
337+
fixture.failures.set(
338+
answerKey(lostSource.scope, memberToken(lostSource.alice.credentialId)),
339+
'scope_lost'
340+
)
341+
const lost = await syncMembers(lostSource.connectorId, [lostSource.alice.id])
342+
expect(lost).toMatchObject({ membersFailed: 0, observationsRemoved: 3 })
343+
344+
expect(await searchableBy(ids.aliceId, lostDocs)).toEqual([])
345+
expect(await searchableBy(ids.aliceId, keptDocs)).toEqual(keptIds)
346+
expect(await observationCount(keptSource.alice.id)).toBe(2)
347+
for (const row of await connectorDocuments(keptSource.connectorId))
348+
expect(row.acl).toContain(keptSource.alice.subjectToken)
349+
}, 60_000)
350+
})

0 commit comments

Comments
 (0)