|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | + |
| 5 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 6 | + |
| 7 | +const mocks = vi.hoisted(() => ({ |
| 8 | + resolveWorkspace: vi.fn(), |
| 9 | + resolvePermission: vi.fn(), |
| 10 | + getKnowledgeBase: vi.fn(), |
| 11 | + resolveBilling: vi.fn(), |
| 12 | + checkUsage: vi.fn(), |
| 13 | + checkActorUsage: vi.fn(), |
| 14 | + generateEmbedding: vi.fn(), |
| 15 | + executeSearch: vi.fn(), |
| 16 | + getDocumentMetadata: vi.fn(), |
| 17 | + getTagDefinitions: vi.fn(), |
| 18 | + recordEmbeddingUsage: vi.fn(), |
| 19 | +})) |
| 20 | + |
| 21 | +vi.mock('@sim/platform-authz/workspace', () => ({ |
| 22 | + permissionSatisfies: (actual: string | null, required: string) => { |
| 23 | + const rank = { read: 1, write: 2, admin: 3 } as const |
| 24 | + return ( |
| 25 | + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] |
| 26 | + ) |
| 27 | + }, |
| 28 | + resolveEffectiveWorkspacePermission: mocks.resolvePermission, |
| 29 | +})) |
| 30 | + |
| 31 | +vi.mock('@/lib/billing/core/billing-attribution', () => ({ |
| 32 | + resolveBillingAttribution: mocks.resolveBilling, |
| 33 | + resolveSystemBillingAttribution: mocks.resolveBilling, |
| 34 | + checkAttributedUsageLimits: mocks.checkUsage, |
| 35 | +})) |
| 36 | + |
| 37 | +/** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */ |
| 38 | +vi.mock('@/lib/knowledge/access/availability', () => ({ |
| 39 | + isKnowledgeMemberAccessAvailable: async () => false, |
| 40 | +})) |
| 41 | + |
| 42 | +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ |
| 43 | + checkActorUsageLimits: mocks.checkActorUsage, |
| 44 | +})) |
| 45 | + |
| 46 | +vi.mock('@/lib/knowledge/application/contexts', () => ({ |
| 47 | + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, |
| 48 | +})) |
| 49 | + |
| 50 | +vi.mock('@/lib/knowledge/service', () => ({ |
| 51 | + getKnowledgeBaseById: mocks.getKnowledgeBase, |
| 52 | +})) |
| 53 | + |
| 54 | +vi.mock('@/lib/knowledge/embeddings', () => ({ |
| 55 | + generateSearchEmbedding: mocks.generateEmbedding, |
| 56 | + recordSearchEmbeddingUsage: mocks.recordEmbeddingUsage, |
| 57 | +})) |
| 58 | + |
| 59 | +vi.mock('@/lib/knowledge/search/queries', () => ({ |
| 60 | + generateSearchEmbedding: mocks.generateEmbedding, |
| 61 | + executeKnowledgeSearch: mocks.executeSearch, |
| 62 | + getDocumentMetadataByIds: mocks.getDocumentMetadata, |
| 63 | +})) |
| 64 | + |
| 65 | +vi.mock('@/lib/knowledge/tags/service', () => ({ |
| 66 | + getDocumentTagDefinitions: mocks.getTagDefinitions, |
| 67 | +})) |
| 68 | + |
| 69 | +vi.mock('@/lib/knowledge/tags/utils', () => ({ |
| 70 | + buildUndefinedTagsError: (tags: string[]) => `Undefined tags: ${tags.join(', ')}`, |
| 71 | + validateTagValue: () => null, |
| 72 | +})) |
| 73 | + |
| 74 | +import { searchKnowledge } from '@/lib/knowledge/application/search' |
| 75 | + |
| 76 | +const workspace = { |
| 77 | + workspaceId: 'workspace-1', |
| 78 | + workspaceOrganizationId: null, |
| 79 | + allowPersonalApiKeys: true, |
| 80 | + billedAccountUserId: 'billing-owner-1', |
| 81 | +} |
| 82 | + |
| 83 | +const knowledgeBase = { |
| 84 | + id: 'knowledge-1', |
| 85 | + userId: 'user-1', |
| 86 | + name: 'Docs', |
| 87 | + workspaceId: 'workspace-1', |
| 88 | + embeddingModel: 'text-embedding-3-small', |
| 89 | + embeddingDimension: 1536, |
| 90 | +} |
| 91 | + |
| 92 | +import { document, embedding } from '@sim/db/schema' |
| 93 | +import { sha256Hex } from '@sim/security/hash' |
| 94 | +import { |
| 95 | + queueTableRows, |
| 96 | + resetDbChainMock, |
| 97 | + V2_OPERATION_RATE_LIMIT_ALLOWED, |
| 98 | + V2_PREAUTH_RATE_LIMIT_ALLOWED, |
| 99 | + v2ApiKeyAuthModuleMock, |
| 100 | + v2RateLimiterModuleMock, |
| 101 | + v2RouteMocks, |
| 102 | +} from '@sim/testing' |
| 103 | +import { NextRequest } from 'next/server' |
| 104 | +import { env } from '@/lib/core/config/env' |
| 105 | +import { |
| 106 | + isDurableSecretProvenanceEnforced, |
| 107 | + resetDurableSecretProvenanceEnforcementCache, |
| 108 | +} from '@/lib/execution/durable-secret-provenance-enforcement' |
| 109 | +import { createKnowledgeDocumentSourceValue } from '@/lib/knowledge/secret-provenance' |
| 110 | +import { POST } from '@/app/api/v2/knowledge/search/route' |
| 111 | +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' |
| 112 | + |
| 113 | +const provider = vi.hoisted(() => ({ fetch: vi.fn(), decrypt: vi.fn() })) |
| 114 | +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) |
| 115 | +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) |
| 116 | +vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: async () => null })) |
| 117 | +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: provider.decrypt })) |
| 118 | + |
| 119 | +const SECRET = 'synthetic-audit-secret-7b88a2' |
| 120 | +const CONTENT = `Stored knowledge contains ${SECRET} in this synthetic fixture.` |
| 121 | +const HASH = sha256Hex(CONTENT) |
| 122 | +const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const |
| 123 | +const requestInput = { |
| 124 | + workspaceId: 'workspace-1', |
| 125 | + knowledgeBaseIds: ['knowledge-1'], |
| 126 | + query: 'find the fixture', |
| 127 | + topK: 1, |
| 128 | + rerankerEnabled: true, |
| 129 | + rerankerModel: 'rerank-v4.0-fast', |
| 130 | +} |
| 131 | +const source = createKnowledgeDocumentSourceValue({ |
| 132 | + filename: 'synthetic.txt', |
| 133 | + fileUrl: 'https://example.invalid/synthetic.txt', |
| 134 | +}) |
| 135 | +const row = { |
| 136 | + id: 'embedding-1', |
| 137 | + documentId: 'document-1', |
| 138 | + knowledgeBaseId: 'knowledge-1', |
| 139 | + content: CONTENT, |
| 140 | + chunkIndex: 0, |
| 141 | + distance: 0.2, |
| 142 | + tag1: null, |
| 143 | + tag2: null, |
| 144 | + tag3: null, |
| 145 | + tag4: null, |
| 146 | + tag5: null, |
| 147 | + tag6: null, |
| 148 | + tag7: null, |
| 149 | + number1: null, |
| 150 | + number2: null, |
| 151 | + number3: null, |
| 152 | + number4: null, |
| 153 | + number5: null, |
| 154 | + date1: null, |
| 155 | + date2: null, |
| 156 | + boolean1: null, |
| 157 | + boolean2: null, |
| 158 | + boolean3: null, |
| 159 | +} |
| 160 | + |
| 161 | +function seedSidecar(status: 'exact' | 'unknown' | 'legacy' | 'missing' | 'stale' | 'malformed') { |
| 162 | + queueTableRows(embedding, [ |
| 163 | + { |
| 164 | + ...row, |
| 165 | + secretProvenanceVersion: status === 'legacy' ? null : 1, |
| 166 | + chunkHash: HASH, |
| 167 | + provenanceContentHash: status === 'stale' ? 'old-hash' : HASH, |
| 168 | + status: status === 'missing' ? null : status === 'unknown' ? 'unknown' : 'exact', |
| 169 | + entries: |
| 170 | + status === 'malformed' |
| 171 | + ? [{ encryptedValue: 123 }] |
| 172 | + : status === 'exact' |
| 173 | + ? [ |
| 174 | + { |
| 175 | + name: 'TOKEN', |
| 176 | + encryptedValue: 'synthetic-encrypted-token', |
| 177 | + sourceUserId: 'user-1', |
| 178 | + sourceWorkspaceId: 'workspace-1', |
| 179 | + }, |
| 180 | + ] |
| 181 | + : [], |
| 182 | + }, |
| 183 | + ]) |
| 184 | + queueTableRows(document, [ |
| 185 | + { |
| 186 | + id: 'document-1', |
| 187 | + ...source, |
| 188 | + secretProvenanceVersion: null, |
| 189 | + provenanceSourceHash: null, |
| 190 | + status: null, |
| 191 | + entries: null, |
| 192 | + }, |
| 193 | + ]) |
| 194 | +} |
| 195 | + |
| 196 | +function providerPayload() { |
| 197 | + expect(provider.fetch).toHaveBeenCalledTimes(1) |
| 198 | + expect(provider.fetch.mock.calls[0][0]).toBe('https://api.cohere.com/v2/rerank') |
| 199 | + return JSON.parse(provider.fetch.mock.calls[0][1].body) |
| 200 | +} |
| 201 | + |
| 202 | +function enforceKnowledge(enforced: boolean) { |
| 203 | + env.DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES = enforced ? 'all' : '' |
| 204 | + resetDurableSecretProvenanceEnforcementCache() |
| 205 | + expect(isDurableSecretProvenanceEnforced('knowledge')).toBe(enforced) |
| 206 | +} |
| 207 | + |
| 208 | +async function requestSearch(overrides: Partial<typeof requestInput> = {}) { |
| 209 | + return POST( |
| 210 | + new NextRequest('http://localhost/api/v2/knowledge/search', { |
| 211 | + method: 'POST', |
| 212 | + headers: { 'content-type': 'application/json', 'x-api-key': 'synthetic-key' }, |
| 213 | + body: JSON.stringify({ ...requestInput, ...overrides }), |
| 214 | + }) |
| 215 | + ) |
| 216 | +} |
| 217 | + |
| 218 | +beforeEach(() => { |
| 219 | + vi.clearAllMocks() |
| 220 | + resetDbChainMock() |
| 221 | + enforceKnowledge(true) |
| 222 | + env.COHERE_API_KEY = 'synthetic-cohere-key' |
| 223 | + provider.decrypt.mockResolvedValue({ decrypted: SECRET }) |
| 224 | + provider.fetch.mockResolvedValue( |
| 225 | + new Response(JSON.stringify({ results: [{ index: 0, relevance_score: 0.9 }] }), { |
| 226 | + status: 200, |
| 227 | + headers: { 'Content-Type': 'application/json' }, |
| 228 | + }) |
| 229 | + ) |
| 230 | + vi.stubGlobal('fetch', provider.fetch) |
| 231 | + mocks.resolveWorkspace.mockResolvedValue(workspace) |
| 232 | + mocks.resolvePermission.mockResolvedValue('read') |
| 233 | + mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) |
| 234 | + mocks.resolveBilling.mockResolvedValue({ actorUserId: 'user-1', workspaceId: 'workspace-1' }) |
| 235 | + mocks.checkUsage.mockResolvedValue({ isExceeded: false }) |
| 236 | + mocks.checkActorUsage.mockResolvedValue({ isExceeded: false }) |
| 237 | + mocks.generateEmbedding.mockResolvedValue({ embedding: [0.1], isBYOK: false }) |
| 238 | + mocks.executeSearch.mockResolvedValue([row]) |
| 239 | + mocks.getDocumentMetadata.mockResolvedValue({ |
| 240 | + 'document-1': { filename: 'synthetic.txt', sourceUrl: null }, |
| 241 | + }) |
| 242 | + mocks.getTagDefinitions.mockResolvedValue([]) |
| 243 | + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) |
| 244 | + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) |
| 245 | + v2RouteMocks.authenticate.mockResolvedValue({ |
| 246 | + principal: PRINCIPAL, |
| 247 | + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], |
| 248 | + rateLimitSubscription: null, |
| 249 | + keyType: 'personal', |
| 250 | + }) |
| 251 | +}) |
| 252 | + |
| 253 | +/** The route, use case, sidecar binding/import, registry, projection and provider request builder are real. */ |
| 254 | +describe('Knowledge search provenance through the V2 route and reranker HTTP boundary', () => { |
| 255 | + it.each([false, true])( |
| 256 | + 'redacts current known-secret chunks with enforcement=%s', |
| 257 | + async (enforced) => { |
| 258 | + enforceKnowledge(enforced) |
| 259 | + seedSidecar('exact') |
| 260 | + const response = await requestSearch() |
| 261 | + const body = await response.json() |
| 262 | + expect(response.status).toBe(200) |
| 263 | + expect(body.data.rerankerStatus).toBe('applied') |
| 264 | + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')]) |
| 265 | + expect(body.data.results[0].content).toBe(CONTENT) |
| 266 | + expect(provider.decrypt).toHaveBeenCalledWith('synthetic-encrypted-token') |
| 267 | + expect(mocks.generateEmbedding).toHaveBeenCalledWith( |
| 268 | + requestInput.query, |
| 269 | + expect.anything(), |
| 270 | + 'workspace-1' |
| 271 | + ) |
| 272 | + } |
| 273 | + ) |
| 274 | + |
| 275 | + it('does not assign a billing owner secret name to a workspace-key caller', async () => { |
| 276 | + seedSidecar('exact') |
| 277 | + v2RouteMocks.authenticate.mockResolvedValue({ |
| 278 | + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, |
| 279 | + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], |
| 280 | + rateLimitSubscription: null, |
| 281 | + keyType: 'workspace', |
| 282 | + }) |
| 283 | + const response = await requestSearch() |
| 284 | + expect(response.status).toBe(200) |
| 285 | + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '[REDACTED_SECRET]')]) |
| 286 | + }) |
| 287 | + |
| 288 | + it('keeps a trusted incoming registry for existing internal and tool callers', async () => { |
| 289 | + seedSidecar('exact') |
| 290 | + const registry = new ResolvedSecretTraceRegistry([], { |
| 291 | + userId: 'user-1', |
| 292 | + workspaceId: 'workspace-1', |
| 293 | + }) |
| 294 | + const result = await searchKnowledge.execute({ |
| 295 | + principal: PRINCIPAL, |
| 296 | + input: { ...requestInput, resultSecretRegistry: registry }, |
| 297 | + }) |
| 298 | + expect(result.rerankerStatus).toBe('applied') |
| 299 | + expect(result.resultSecretRegistry).toBe(registry) |
| 300 | + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')]) |
| 301 | + }) |
| 302 | + |
| 303 | + it.each(['unknown', 'missing', 'stale', 'malformed'] as const)( |
| 304 | + 'refuses %s tracked provenance before provider HTTP when enforcement is enabled', |
| 305 | + async (status) => { |
| 306 | + seedSidecar(status) |
| 307 | + const response = await requestSearch() |
| 308 | + expect(response.status).toBe(409) |
| 309 | + expect(await response.json()).toMatchObject({ |
| 310 | + error: { code: 'CONFLICT', message: 'Knowledge result secret provenance is unavailable' }, |
| 311 | + }) |
| 312 | + expect(provider.fetch).not.toHaveBeenCalled() |
| 313 | + } |
| 314 | + ) |
| 315 | + |
| 316 | + it.each(['unknown', 'missing', 'stale', 'malformed'] as const)( |
| 317 | + 'preserves existing flag-off compatibility for %s sidecars', |
| 318 | + async (status) => { |
| 319 | + enforceKnowledge(false) |
| 320 | + seedSidecar(status) |
| 321 | + const response = await requestSearch() |
| 322 | + expect(response.status).toBe(200) |
| 323 | + expect(providerPayload().documents).toEqual([CONTENT]) |
| 324 | + } |
| 325 | + ) |
| 326 | + |
| 327 | + it.each([false, true])( |
| 328 | + 'keeps pre-tracking NULL rows readable with enforcement=%s', |
| 329 | + async (enforced) => { |
| 330 | + enforceKnowledge(enforced) |
| 331 | + seedSidecar('legacy') |
| 332 | + const response = await requestSearch() |
| 333 | + expect(response.status).toBe(200) |
| 334 | + expect(providerPayload().documents).toEqual([CONTENT]) |
| 335 | + } |
| 336 | + ) |
| 337 | + |
| 338 | + it('does not subject a raw public read without reranking to durable-model enforcement', async () => { |
| 339 | + seedSidecar('unknown') |
| 340 | + const response = await requestSearch({ rerankerEnabled: false }) |
| 341 | + const body = await response.json() |
| 342 | + expect(response.status).toBe(200) |
| 343 | + expect(body.data.results[0].content).toBe(CONTENT) |
| 344 | + expect(provider.fetch).not.toHaveBeenCalled() |
| 345 | + expect(provider.decrypt).not.toHaveBeenCalled() |
| 346 | + }) |
| 347 | +}) |
0 commit comments