|
1 | 1 | import { beforeEach, describe, expect, it, vi } from 'vitest' |
2 | 2 |
|
3 | | -const { mockDecryptSecret } = vi.hoisted(() => ({ |
| 3 | +const { mockDecryptSecret, mockLogger } = vi.hoisted(() => ({ |
4 | 4 | mockDecryptSecret: vi.fn(), |
| 5 | + mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
5 | 6 | })) |
6 | 7 |
|
7 | 8 | vi.mock('@/lib/core/security/encryption', () => ({ |
8 | 9 | decryptSecret: mockDecryptSecret, |
9 | 10 | })) |
10 | 11 |
|
| 12 | +vi.mock('@sim/logger', () => ({ |
| 13 | + createLogger: () => mockLogger, |
| 14 | +})) |
| 15 | + |
11 | 16 | import { |
12 | 17 | ANONYMOUS_SECRET_TRACE_REPLACEMENT, |
13 | 18 | createResolvedSecretTraceRegistry, |
@@ -1279,3 +1284,136 @@ describe('ResolvedSecretTraceRegistry', () => { |
1279 | 1284 | expect(registry.getModelEgressSnapshot()).toEqual({ complete: false }) |
1280 | 1285 | }) |
1281 | 1286 | }) |
| 1287 | + |
| 1288 | +describe('incompleteness diagnostics', () => { |
| 1289 | + const scope = { userId: 'user-1', workspaceId: 'workspace-1' } |
| 1290 | + |
| 1291 | + beforeEach(() => { |
| 1292 | + mockLogger.warn.mockClear() |
| 1293 | + mockLogger.error.mockClear() |
| 1294 | + }) |
| 1295 | + |
| 1296 | + it('reports an originating incompleteness at error so the default log level cannot hide it', () => { |
| 1297 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1298 | + |
| 1299 | + registry.markIncomplete('projection-mismatch') |
| 1300 | + |
| 1301 | + expect(mockLogger.warn).not.toHaveBeenCalled() |
| 1302 | + expect(mockLogger.error).toHaveBeenCalledWith( |
| 1303 | + 'Resolved secret registry marked incomplete', |
| 1304 | + expect.objectContaining({ reason: 'projection-mismatch' }) |
| 1305 | + ) |
| 1306 | + }) |
| 1307 | + |
| 1308 | + it('reports an inherited incompleteness at warn so one fault does not read as several', () => { |
| 1309 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1310 | + |
| 1311 | + registry.markIncomplete('inherited-incomplete-source') |
| 1312 | + |
| 1313 | + expect(mockLogger.error).not.toHaveBeenCalled() |
| 1314 | + expect(mockLogger.warn).toHaveBeenCalledWith( |
| 1315 | + 'Resolved secret registry marked incomplete', |
| 1316 | + expect.objectContaining({ reason: 'inherited-incomplete-source' }) |
| 1317 | + ) |
| 1318 | + }) |
| 1319 | + |
| 1320 | + it('names the guard that tripped rather than reporting unspecified', () => { |
| 1321 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1322 | + |
| 1323 | + registry.recordResolved('MISSING', 'value-not-in-catalog') |
| 1324 | + |
| 1325 | + expect(mockLogger.error).toHaveBeenCalledWith( |
| 1326 | + 'Resolved secret registry marked incomplete', |
| 1327 | + expect.objectContaining({ reason: 'unverified-resolved-entry' }) |
| 1328 | + ) |
| 1329 | + }) |
| 1330 | + |
| 1331 | + it('separates a tool-call scope mismatch from a merged child that was already incomplete', () => { |
| 1332 | + const scopeMismatch = new ResolvedSecretTraceRegistry([], scope) |
| 1333 | + const foreignChild = new ResolvedSecretTraceRegistry([], { |
| 1334 | + userId: 'user-1', |
| 1335 | + workspaceId: 'workspace-2', |
| 1336 | + }) |
| 1337 | + |
| 1338 | + scopeMismatch.mergeToolCallRegistry(foreignChild) |
| 1339 | + |
| 1340 | + expect(mockLogger.error).toHaveBeenCalledWith( |
| 1341 | + 'Resolved secret registry marked incomplete', |
| 1342 | + expect.objectContaining({ reason: 'tool-call-scope-mismatch' }) |
| 1343 | + ) |
| 1344 | + |
| 1345 | + mockLogger.warn.mockClear() |
| 1346 | + mockLogger.error.mockClear() |
| 1347 | + |
| 1348 | + const sameScope = new ResolvedSecretTraceRegistry([], scope) |
| 1349 | + const incompleteChild = new ResolvedSecretTraceRegistry([], scope) |
| 1350 | + incompleteChild.markIncomplete('projection-mismatch') |
| 1351 | + mockLogger.error.mockClear() |
| 1352 | + |
| 1353 | + sameScope.mergeToolCallRegistry(incompleteChild) |
| 1354 | + |
| 1355 | + expect(mockLogger.error).not.toHaveBeenCalled() |
| 1356 | + expect(mockLogger.warn).toHaveBeenCalledWith( |
| 1357 | + 'Resolved secret registry marked incomplete', |
| 1358 | + expect.objectContaining({ reason: 'inherited-incomplete-source' }) |
| 1359 | + ) |
| 1360 | + }) |
| 1361 | + |
| 1362 | + it('attributes an already-incomplete bundle to its source rather than to the value filter', async () => { |
| 1363 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1364 | + |
| 1365 | + await registry.importProvenanceForValue( |
| 1366 | + { version: 1, complete: false, entries: [], scope }, |
| 1367 | + 'x', |
| 1368 | + { |
| 1369 | + trusted: true, |
| 1370 | + inputPath: ['prompt'], |
| 1371 | + } |
| 1372 | + ) |
| 1373 | + |
| 1374 | + const reasons = mockLogger.error.mock.calls |
| 1375 | + .concat(mockLogger.warn.mock.calls) |
| 1376 | + .map(([, details]) => (details as { reason?: string })?.reason) |
| 1377 | + expect(reasons).toContain('source-provenance-incomplete') |
| 1378 | + expect(reasons).not.toContain('value-provenance-filter-incomplete') |
| 1379 | + }) |
| 1380 | + |
| 1381 | + it('reports an incoming incomplete bundle at error, since its origin logged in another process', () => { |
| 1382 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1383 | + |
| 1384 | + registry.markIncomplete('source-provenance-incomplete') |
| 1385 | + |
| 1386 | + expect(mockLogger.warn).not.toHaveBeenCalled() |
| 1387 | + expect(mockLogger.error).toHaveBeenCalledWith( |
| 1388 | + 'Resolved secret registry marked incomplete', |
| 1389 | + expect.objectContaining({ reason: 'source-provenance-incomplete' }) |
| 1390 | + ) |
| 1391 | + }) |
| 1392 | + |
| 1393 | + it('reports an incoming incomplete bundle exactly once, from the registry that knows the path', async () => { |
| 1394 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1395 | + |
| 1396 | + await registry.importProvenanceForValue( |
| 1397 | + { version: 1, complete: false, entries: [], scope }, |
| 1398 | + 'x', |
| 1399 | + { trusted: true, inputPath: ['prompt'] } |
| 1400 | + ) |
| 1401 | + |
| 1402 | + const records = mockLogger.error.mock.calls.concat(mockLogger.warn.mock.calls) |
| 1403 | + expect(records).toHaveLength(1) |
| 1404 | + expect(records[0]).toEqual([ |
| 1405 | + 'Resolved secret input path marked incomplete', |
| 1406 | + expect.objectContaining({ reason: 'source-provenance-incomplete', inputPath: 'prompt' }), |
| 1407 | + ]) |
| 1408 | + }) |
| 1409 | + |
| 1410 | + it('records no secret material alongside the reason', () => { |
| 1411 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1412 | + |
| 1413 | + registry.recordResolved('MISSING', 'super-secret-value') |
| 1414 | + |
| 1415 | + const logged = JSON.stringify(mockLogger.error.mock.calls) |
| 1416 | + expect(logged).not.toContain('super-secret-value') |
| 1417 | + expect(logged).not.toContain('MISSING') |
| 1418 | + }) |
| 1419 | +}) |
0 commit comments