|
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, |
| 18 | + createIncompleteResolvedSecretTraceRegistry, |
13 | 19 | createResolvedSecretTraceRegistry, |
14 | 20 | isResolvedSecretTraceProvenanceV1, |
15 | 21 | RESOLVED_SECRET_TRACE_CHECKPOINT_VERSION, |
@@ -1279,3 +1285,181 @@ describe('ResolvedSecretTraceRegistry', () => { |
1279 | 1285 | expect(registry.getModelEgressSnapshot()).toEqual({ complete: false }) |
1280 | 1286 | }) |
1281 | 1287 | }) |
| 1288 | + |
| 1289 | +describe('incompleteness diagnostics', () => { |
| 1290 | + const scope = { userId: 'user-1', workspaceId: 'workspace-1' } |
| 1291 | + |
| 1292 | + beforeEach(() => { |
| 1293 | + mockLogger.warn.mockClear() |
| 1294 | + mockLogger.error.mockClear() |
| 1295 | + }) |
| 1296 | + |
| 1297 | + it('reports an originating incompleteness at error so the default log level cannot hide it', () => { |
| 1298 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1299 | + |
| 1300 | + registry.markIncomplete('projection-mismatch') |
| 1301 | + |
| 1302 | + expect(mockLogger.warn).not.toHaveBeenCalled() |
| 1303 | + expect(mockLogger.error).toHaveBeenCalledWith( |
| 1304 | + 'Resolved secret registry marked incomplete', |
| 1305 | + expect.objectContaining({ reason: 'projection-mismatch' }) |
| 1306 | + ) |
| 1307 | + }) |
| 1308 | + |
| 1309 | + it('reports an inherited incompleteness at warn so one fault does not read as several', () => { |
| 1310 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1311 | + |
| 1312 | + registry.markIncomplete('inherited-incomplete-source') |
| 1313 | + |
| 1314 | + expect(mockLogger.error).not.toHaveBeenCalled() |
| 1315 | + expect(mockLogger.warn).toHaveBeenCalledWith( |
| 1316 | + 'Resolved secret registry marked incomplete', |
| 1317 | + expect.objectContaining({ reason: 'inherited-incomplete-source' }) |
| 1318 | + ) |
| 1319 | + }) |
| 1320 | + |
| 1321 | + it('names the guard that tripped rather than reporting unspecified', () => { |
| 1322 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1323 | + |
| 1324 | + registry.recordResolved('MISSING', 'value-not-in-catalog') |
| 1325 | + |
| 1326 | + expect(mockLogger.error).toHaveBeenCalledWith( |
| 1327 | + 'Resolved secret registry marked incomplete', |
| 1328 | + expect.objectContaining({ reason: 'unverified-resolved-entry' }) |
| 1329 | + ) |
| 1330 | + }) |
| 1331 | + |
| 1332 | + it('separates a tool-call scope mismatch from a merged child that was already incomplete', () => { |
| 1333 | + const scopeMismatch = new ResolvedSecretTraceRegistry([], scope) |
| 1334 | + const foreignChild = new ResolvedSecretTraceRegistry([], { |
| 1335 | + userId: 'user-1', |
| 1336 | + workspaceId: 'workspace-2', |
| 1337 | + }) |
| 1338 | + |
| 1339 | + scopeMismatch.mergeToolCallRegistry(foreignChild) |
| 1340 | + |
| 1341 | + expect(mockLogger.error).toHaveBeenCalledWith( |
| 1342 | + 'Resolved secret registry marked incomplete', |
| 1343 | + expect.objectContaining({ reason: 'tool-call-scope-mismatch' }) |
| 1344 | + ) |
| 1345 | + |
| 1346 | + mockLogger.warn.mockClear() |
| 1347 | + mockLogger.error.mockClear() |
| 1348 | + |
| 1349 | + const sameScope = new ResolvedSecretTraceRegistry([], scope) |
| 1350 | + const incompleteChild = new ResolvedSecretTraceRegistry([], scope) |
| 1351 | + incompleteChild.markIncomplete('projection-mismatch') |
| 1352 | + mockLogger.error.mockClear() |
| 1353 | + |
| 1354 | + sameScope.mergeToolCallRegistry(incompleteChild) |
| 1355 | + |
| 1356 | + expect(mockLogger.error).not.toHaveBeenCalled() |
| 1357 | + expect(mockLogger.warn).toHaveBeenCalledWith( |
| 1358 | + 'Resolved secret registry marked incomplete', |
| 1359 | + expect.objectContaining({ reason: 'inherited-incomplete-source' }) |
| 1360 | + ) |
| 1361 | + }) |
| 1362 | + |
| 1363 | + it('attributes an already-incomplete bundle to its source rather than to the value filter', async () => { |
| 1364 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1365 | + |
| 1366 | + await registry.importProvenanceForValue( |
| 1367 | + { version: 1, complete: false, entries: [], scope }, |
| 1368 | + 'x', |
| 1369 | + { |
| 1370 | + trusted: true, |
| 1371 | + inputPath: ['prompt'], |
| 1372 | + } |
| 1373 | + ) |
| 1374 | + |
| 1375 | + const reasons = mockLogger.error.mock.calls |
| 1376 | + .concat(mockLogger.warn.mock.calls) |
| 1377 | + .map(([, details]) => (details as { reason?: string })?.reason) |
| 1378 | + expect(reasons).toContain('source-provenance-incomplete') |
| 1379 | + expect(reasons).not.toContain('value-provenance-filter-incomplete') |
| 1380 | + }) |
| 1381 | + |
| 1382 | + it('reports an incoming incomplete bundle at warn, since no catalog was ever on offer', () => { |
| 1383 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1384 | + |
| 1385 | + registry.markIncomplete('source-provenance-incomplete') |
| 1386 | + |
| 1387 | + expect(mockLogger.error).not.toHaveBeenCalled() |
| 1388 | + expect(mockLogger.warn).toHaveBeenCalledWith( |
| 1389 | + 'Resolved secret registry marked incomplete', |
| 1390 | + expect.objectContaining({ reason: 'source-provenance-incomplete' }) |
| 1391 | + ) |
| 1392 | + }) |
| 1393 | + |
| 1394 | + it('stays silent for a registry built incomplete by design, which sits on hot paths', () => { |
| 1395 | + createIncompleteResolvedSecretTraceRegistry(scope) |
| 1396 | + |
| 1397 | + expect(mockLogger.error).not.toHaveBeenCalled() |
| 1398 | + expect(mockLogger.warn).not.toHaveBeenCalled() |
| 1399 | + }) |
| 1400 | + |
| 1401 | + it('keeps an unaudited caller taking the default reason out of the error stream', () => { |
| 1402 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1403 | + |
| 1404 | + registry.markIncomplete() |
| 1405 | + |
| 1406 | + expect(mockLogger.error).not.toHaveBeenCalled() |
| 1407 | + expect(mockLogger.warn).toHaveBeenCalledWith( |
| 1408 | + 'Resolved secret registry marked incomplete', |
| 1409 | + expect.objectContaining({ reason: 'unspecified' }) |
| 1410 | + ) |
| 1411 | + }) |
| 1412 | + |
| 1413 | + it('reports an incoming incomplete bundle exactly once, from the registry that knows the path', async () => { |
| 1414 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1415 | + |
| 1416 | + await registry.importProvenanceForValue( |
| 1417 | + { version: 1, complete: false, entries: [], scope }, |
| 1418 | + 'x', |
| 1419 | + { trusted: true, inputPath: ['prompt'] } |
| 1420 | + ) |
| 1421 | + |
| 1422 | + const records = mockLogger.error.mock.calls.concat(mockLogger.warn.mock.calls) |
| 1423 | + expect(records).toHaveLength(1) |
| 1424 | + expect(records[0]).toEqual([ |
| 1425 | + 'Resolved secret input path marked incomplete', |
| 1426 | + expect.objectContaining({ reason: 'source-provenance-incomplete', inputPath: 'prompt' }), |
| 1427 | + ]) |
| 1428 | + }) |
| 1429 | + |
| 1430 | + it('summarises decrypt failures once per import instead of once per entry', async () => { |
| 1431 | + mockDecryptSecret.mockRejectedValue(new Error('key rotated')) |
| 1432 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1433 | + |
| 1434 | + await registry.importProvenance( |
| 1435 | + { |
| 1436 | + version: 1, |
| 1437 | + complete: true, |
| 1438 | + entries: Array.from({ length: 25 }, (_, i) => ({ |
| 1439 | + name: `SECRET_${i}`, |
| 1440 | + encryptedValue: `encrypted-${i}`, |
| 1441 | + })), |
| 1442 | + scope, |
| 1443 | + }, |
| 1444 | + { trusted: true } |
| 1445 | + ) |
| 1446 | + |
| 1447 | + const decryptRecords = mockLogger.error.mock.calls.filter( |
| 1448 | + ([message]) => message === 'Provenance entries could not be decrypted' |
| 1449 | + ) |
| 1450 | + expect(decryptRecords).toHaveLength(1) |
| 1451 | + expect(decryptRecords[0][1]).toEqual( |
| 1452 | + expect.objectContaining({ failedEntryCount: 25, totalEntryCount: 25, error: 'key rotated' }) |
| 1453 | + ) |
| 1454 | + }) |
| 1455 | + |
| 1456 | + it('records no secret material alongside the reason', () => { |
| 1457 | + const registry = new ResolvedSecretTraceRegistry([], scope) |
| 1458 | + |
| 1459 | + registry.recordResolved('MISSING', 'super-secret-value') |
| 1460 | + |
| 1461 | + const logged = JSON.stringify(mockLogger.error.mock.calls) |
| 1462 | + expect(logged).not.toContain('super-secret-value') |
| 1463 | + expect(logged).not.toContain('MISSING') |
| 1464 | + }) |
| 1465 | +}) |
0 commit comments