|
| 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