github-issue-tower-defence-management 1.148.25 → 1.149.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -5
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -0
  6. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  7. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js +126 -0
  8. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js.map +1 -0
  9. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +1 -0
  10. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -1
  11. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +5 -3
  12. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  13. package/bin/domain/entities/KevReportWatermark.js +3 -0
  14. package/bin/domain/entities/KevReportWatermark.js.map +1 -0
  15. package/bin/domain/usecases/DailySecurityScanUseCase.js +73 -19
  16. package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
  17. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +9 -0
  18. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  19. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +12 -11
  20. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  21. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js +3 -0
  22. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
  25. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +35 -0
  26. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +7 -0
  27. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.test.ts +253 -0
  28. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.ts +130 -0
  29. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +2 -0
  30. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +1 -0
  31. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +3 -0
  32. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +159 -0
  33. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +7 -1
  34. package/src/domain/entities/KevReportWatermark.ts +4 -0
  35. package/src/domain/entities/LiveSessionActivitySnapshot.ts +1 -0
  36. package/src/domain/usecases/DailySecurityScanUseCase.test.ts +461 -0
  37. package/src/domain/usecases/DailySecurityScanUseCase.ts +112 -29
  38. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +58 -1
  39. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +21 -0
  40. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +146 -8
  41. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +19 -10
  42. package/src/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.ts +11 -0
  43. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +3 -0
  44. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  45. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +2 -0
  46. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  47. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts +10 -0
  48. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts.map +1 -0
  49. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -1
  50. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  51. package/types/domain/entities/KevReportWatermark.d.ts +5 -0
  52. package/types/domain/entities/KevReportWatermark.d.ts.map +1 -0
  53. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +1 -0
  54. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  55. package/types/domain/usecases/DailySecurityScanUseCase.d.ts +3 -1
  56. package/types/domain/usecases/DailySecurityScanUseCase.d.ts.map +1 -1
  57. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -0
  58. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  59. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  60. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts +15 -0
  61. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts.map +1 -0
  62. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +1 -0
  63. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -344,6 +344,7 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
344
344
  filePath,
345
345
  fileName,
346
346
  nowEpochSeconds,
347
+ liveSubAgentIds !== null,
347
348
  loadNormalizedProcessCommandLines,
348
349
  );
349
350
  if (activity !== null) {
@@ -357,6 +358,7 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
357
358
  filePath: string,
358
359
  fileName: string,
359
360
  nowEpochSeconds: number,
361
+ listedInRunningSubAgentsRecord: boolean,
360
362
  loadNormalizedProcessCommandLines: () => Promise<string[]>,
361
363
  ): Promise<SubAgentActivity | null> => {
362
364
  let content: string;
@@ -368,7 +370,10 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
368
370
  return null;
369
371
  }
370
372
  const transcript = parseTranscript(content);
371
- if (transcript.lastEntryIndicatesCompletion) {
373
+ if (
374
+ transcript.lastEntryIndicatesCompletion &&
375
+ !listedInRunningSubAgentsRecord
376
+ ) {
372
377
  return null;
373
378
  }
374
379
  const silentSeconds = clampToZero(
@@ -388,6 +393,7 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
388
393
  transcript.pendingToolCommands,
389
394
  loadNormalizedProcessCommandLines,
390
395
  )),
396
+ finishedResultUnconsumed: transcript.lastEntryIndicatesCompletion,
391
397
  };
392
398
  };
393
399
 
@@ -0,0 +1,4 @@
1
+ export type KevReportWatermark = {
2
+ lastReportedDateAdded: string;
3
+ reportedCveIdsOnLastReportedDateAdded: string[];
4
+ };
@@ -3,6 +3,7 @@ export type SubAgentActivity = {
3
3
  silentSeconds: number;
4
4
  runningSeconds: number;
5
5
  waitingOnExternalProcess: boolean;
6
+ finishedResultUnconsumed: boolean;
6
7
  };
7
8
 
8
9
  export type LiveSessionActivitySnapshot = {
@@ -2,6 +2,7 @@ import { DailySecurityScanUseCase } from './DailySecurityScanUseCase';
2
2
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
3
3
  import { IssueRepository } from './adapter-interfaces/IssueRepository';
4
4
  import { HttpRepository } from './adapter-interfaces/HttpRepository';
5
+ import { KevReportWatermarkRepository } from './adapter-interfaces/KevReportWatermarkRepository';
5
6
  import { mock } from 'jest-mock-extended';
6
7
 
7
8
  const KEV_CATALOG_URL =
@@ -12,16 +13,21 @@ describe('DailySecurityScanUseCase', () => {
12
13
  const mockLocalCommandRunner = mock<LocalCommandRunner>();
13
14
  const mockIssueRepository = mock<IssueRepository>();
14
15
  const mockHttpRepository = mock<HttpRepository>();
16
+ const mockKevReportWatermarkRepository =
17
+ mock<KevReportWatermarkRepository>();
18
+ mockKevReportWatermarkRepository.load.mockResolvedValue({ type: 'absent' });
15
19
  const useCase = new DailySecurityScanUseCase(
16
20
  mockLocalCommandRunner,
17
21
  mockIssueRepository,
18
22
  mockHttpRepository,
23
+ mockKevReportWatermarkRepository,
19
24
  );
20
25
  return {
21
26
  useCase,
22
27
  mockLocalCommandRunner,
23
28
  mockIssueRepository,
24
29
  mockHttpRepository,
30
+ mockKevReportWatermarkRepository,
25
31
  };
26
32
  };
27
33
 
@@ -903,6 +909,210 @@ describe('DailySecurityScanUseCase', () => {
903
909
  errorSpy.mockRestore();
904
910
  });
905
911
 
912
+ it('logs an error and continues scanning remaining repositories when creating the findings issue fails', async () => {
913
+ const { useCase, mockLocalCommandRunner, mockIssueRepository } =
914
+ buildUseCase();
915
+ const errorSpy = jest
916
+ .spyOn(console, 'error')
917
+ .mockImplementation(() => undefined);
918
+
919
+ mockIssueRepository.searchIssue.mockResolvedValue([]);
920
+ mockIssueRepository.createNewIssue.mockImplementation(
921
+ async (_org, repositoryName) => {
922
+ if (repositoryName === 'archived') {
923
+ throw new Error(
924
+ 'Request failed with status code 403: Repository was archived so is read-only.',
925
+ );
926
+ }
927
+ return 1;
928
+ },
929
+ );
930
+
931
+ mockLocalCommandRunner.runCommand.mockImplementation(
932
+ async (program, args) => {
933
+ if (program === 'find') {
934
+ return {
935
+ stdout:
936
+ '/repos/example-org/archived/.git\n/repos/example-org/app/.git\n',
937
+ stderr: '',
938
+ exitCode: 0,
939
+ };
940
+ }
941
+ if (program === 'mktemp') {
942
+ return {
943
+ stdout: `/tmp/${args[args.length - 1].replace('XXXXXX', 'abc123')}\n`,
944
+ stderr: '',
945
+ exitCode: 0,
946
+ };
947
+ }
948
+ if (program === 'git') {
949
+ const dir = args[1];
950
+ const repo = dir.split('/').pop() ?? '';
951
+ return {
952
+ stdout: `git@github.com:example-org/${repo}.git\n`,
953
+ stderr: '',
954
+ exitCode: 0,
955
+ };
956
+ }
957
+ if (program === 'osv-scanner') {
958
+ return {
959
+ stdout: JSON.stringify({
960
+ results: [
961
+ {
962
+ source: {
963
+ path: args[args.indexOf('-r') + 1],
964
+ type: 'lockfile',
965
+ },
966
+ packages: [
967
+ {
968
+ package: {
969
+ name: 'example-library',
970
+ version: '1.2.3',
971
+ ecosystem: 'npm',
972
+ },
973
+ vulnerabilities: [
974
+ {
975
+ id: 'GHSA-1111-2222-3333',
976
+ aliases: [],
977
+ summary: 'Example Library Deserialization',
978
+ },
979
+ ],
980
+ },
981
+ ],
982
+ },
983
+ ],
984
+ }),
985
+ stderr: '',
986
+ exitCode: 1,
987
+ };
988
+ }
989
+ return { stdout: '', stderr: '', exitCode: 0 };
990
+ },
991
+ );
992
+
993
+ await useCase.run({
994
+ targetDates: [new Date('2024-01-02T05:00:00Z')],
995
+ org: 'example-org',
996
+ manager: 'manager-name',
997
+ dailySecurityScan: {
998
+ scanBaseDirectory: '/repos',
999
+ targetHourUtc: 5,
1000
+ },
1001
+ });
1002
+
1003
+ expect(errorSpy).toHaveBeenCalledWith(
1004
+ expect.stringContaining('archived'),
1005
+ );
1006
+ expect(
1007
+ mockIssueRepository.createNewIssue.mock.calls.map((call) => call[1]),
1008
+ ).toEqual(['archived', 'app']);
1009
+ errorSpy.mockRestore();
1010
+ });
1011
+
1012
+ it('logs an error and continues scanning remaining repositories when commenting on the existing findings issue fails', async () => {
1013
+ const { useCase, mockLocalCommandRunner, mockIssueRepository } =
1014
+ buildUseCase();
1015
+ const errorSpy = jest
1016
+ .spyOn(console, 'error')
1017
+ .mockImplementation(() => undefined);
1018
+
1019
+ mockIssueRepository.searchIssue.mockImplementation(
1020
+ async ({ repositoryName }) =>
1021
+ repositoryName === 'oversized'
1022
+ ? [
1023
+ {
1024
+ url: 'https://github.com/example-org/oversized/issues/1',
1025
+ title: 'Daily security scan findings',
1026
+ number: '1',
1027
+ },
1028
+ ]
1029
+ : [],
1030
+ );
1031
+ mockIssueRepository.createCommentByUrl.mockRejectedValue(
1032
+ new Error('Request failed with status code 422: Validation Failed'),
1033
+ );
1034
+
1035
+ mockLocalCommandRunner.runCommand.mockImplementation(
1036
+ async (program, args) => {
1037
+ if (program === 'find') {
1038
+ return {
1039
+ stdout:
1040
+ '/repos/example-org/oversized/.git\n/repos/example-org/app/.git\n',
1041
+ stderr: '',
1042
+ exitCode: 0,
1043
+ };
1044
+ }
1045
+ if (program === 'mktemp') {
1046
+ return {
1047
+ stdout: `/tmp/${args[args.length - 1].replace('XXXXXX', 'abc123')}\n`,
1048
+ stderr: '',
1049
+ exitCode: 0,
1050
+ };
1051
+ }
1052
+ if (program === 'git') {
1053
+ const dir = args[1];
1054
+ const repo = dir.split('/').pop() ?? '';
1055
+ return {
1056
+ stdout: `git@github.com:example-org/${repo}.git\n`,
1057
+ stderr: '',
1058
+ exitCode: 0,
1059
+ };
1060
+ }
1061
+ if (program === 'osv-scanner') {
1062
+ return {
1063
+ stdout: JSON.stringify({
1064
+ results: [
1065
+ {
1066
+ source: {
1067
+ path: args[args.indexOf('-r') + 1],
1068
+ type: 'lockfile',
1069
+ },
1070
+ packages: [
1071
+ {
1072
+ package: {
1073
+ name: 'example-library',
1074
+ version: '1.2.3',
1075
+ ecosystem: 'npm',
1076
+ },
1077
+ vulnerabilities: [
1078
+ {
1079
+ id: 'GHSA-1111-2222-3333',
1080
+ aliases: [],
1081
+ summary: 'Example Library Deserialization',
1082
+ },
1083
+ ],
1084
+ },
1085
+ ],
1086
+ },
1087
+ ],
1088
+ }),
1089
+ stderr: '',
1090
+ exitCode: 1,
1091
+ };
1092
+ }
1093
+ return { stdout: '', stderr: '', exitCode: 0 };
1094
+ },
1095
+ );
1096
+
1097
+ await useCase.run({
1098
+ targetDates: [new Date('2024-01-02T05:00:00Z')],
1099
+ org: 'example-org',
1100
+ manager: 'manager-name',
1101
+ dailySecurityScan: {
1102
+ scanBaseDirectory: '/repos',
1103
+ targetHourUtc: 5,
1104
+ },
1105
+ });
1106
+
1107
+ expect(errorSpy).toHaveBeenCalledWith(
1108
+ expect.stringContaining('oversized'),
1109
+ );
1110
+ expect(
1111
+ mockIssueRepository.createNewIssue.mock.calls.map((call) => call[1]),
1112
+ ).toEqual(['app']);
1113
+ errorSpy.mockRestore();
1114
+ });
1115
+
906
1116
  it('adds a comment to the existing open issue for the repository instead of creating a new one', async () => {
907
1117
  const { useCase, mockLocalCommandRunner, mockIssueRepository } =
908
1118
  buildUseCase();
@@ -1105,6 +1315,53 @@ describe('DailySecurityScanUseCase', () => {
1105
1315
  },
1106
1316
  });
1107
1317
 
1318
+ it('still reports KEV additions when the scanned repository findings issue cannot be written', async () => {
1319
+ const { useCase, mockIssueRepository } = buildScanEnvironment(
1320
+ osvScanOutput([
1321
+ {
1322
+ name: 'example-library',
1323
+ version: '1.2.3',
1324
+ ecosystem: 'npm',
1325
+ id: 'GHSA-1111-2222-3333',
1326
+ aliases: ['CVE-2024-0001'],
1327
+ summary: 'Example Library Deserialization',
1328
+ },
1329
+ ]),
1330
+ {
1331
+ vulnerabilities: [
1332
+ {
1333
+ cveID: 'CVE-2024-0001',
1334
+ vendorProject: 'Example',
1335
+ product: 'Example Library',
1336
+ vulnerabilityName: 'Example Library Deserialization',
1337
+ dateAdded: '2024-01-02',
1338
+ },
1339
+ ],
1340
+ },
1341
+ );
1342
+ const errorSpy = jest
1343
+ .spyOn(console, 'error')
1344
+ .mockImplementation(() => undefined);
1345
+ mockIssueRepository.createNewIssue.mockImplementation(
1346
+ async (_org, repositoryName) => {
1347
+ if (repositoryName === 'app') {
1348
+ throw new Error(
1349
+ 'Request failed with status code 403: Repository was archived so is read-only.',
1350
+ );
1351
+ }
1352
+ return 1;
1353
+ },
1354
+ );
1355
+
1356
+ await runWithKevReporting(useCase);
1357
+
1358
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('app'));
1359
+ expect(
1360
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1361
+ ).toHaveLength(1);
1362
+ errorSpy.mockRestore();
1363
+ });
1364
+
1108
1365
  it('reports a KEV addition whose CVE the scanner found at an installed version', async () => {
1109
1366
  const { useCase, mockIssueRepository } = buildScanEnvironment(
1110
1367
  osvScanOutput([
@@ -1238,5 +1495,209 @@ describe('DailySecurityScanUseCase', () => {
1238
1495
  );
1239
1496
  expect(grepCalls).toHaveLength(0);
1240
1497
  });
1498
+
1499
+ const scannedExampleLibrary = osvScanOutput([
1500
+ {
1501
+ name: 'example-library',
1502
+ version: '1.2.3',
1503
+ ecosystem: 'npm',
1504
+ id: 'GHSA-1111-2222-3333',
1505
+ aliases: ['CVE-2024-0001', 'CVE-2024-0002', 'CVE-2024-0003'],
1506
+ summary: 'Example Library Deserialization',
1507
+ },
1508
+ ]);
1509
+
1510
+ const kevCatalogOf = (
1511
+ vulnerabilities: { cveID: string; dateAdded: string }[],
1512
+ ) => ({
1513
+ vulnerabilities: vulnerabilities.map((vulnerability) => ({
1514
+ cveID: vulnerability.cveID,
1515
+ vendorProject: 'Example',
1516
+ product: 'ExampleLibrary',
1517
+ vulnerabilityName: `${vulnerability.cveID} Deserialization`,
1518
+ dateAdded: vulnerability.dateAdded,
1519
+ })),
1520
+ });
1521
+
1522
+ it('records every addition it considered in the watermark after the report issue is created', async () => {
1523
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1524
+ buildScanEnvironment(
1525
+ scannedExampleLibrary,
1526
+ kevCatalogOf([
1527
+ { cveID: 'CVE-2024-0001', dateAdded: '2024-01-01' },
1528
+ { cveID: 'CVE-2024-0002', dateAdded: '2024-01-02' },
1529
+ ]),
1530
+ );
1531
+
1532
+ await runWithKevReporting(useCase);
1533
+
1534
+ expect(
1535
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1536
+ ).toHaveLength(1);
1537
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1538
+ [
1539
+ {
1540
+ lastReportedDateAdded: '2024-01-02',
1541
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0002'],
1542
+ },
1543
+ ],
1544
+ ]);
1545
+ });
1546
+
1547
+ it('does not report an addition that the stored watermark already covers', async () => {
1548
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1549
+ buildScanEnvironment(
1550
+ scannedExampleLibrary,
1551
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1552
+ );
1553
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1554
+ type: 'stored',
1555
+ watermark: {
1556
+ lastReportedDateAdded: '2024-01-02',
1557
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0001'],
1558
+ },
1559
+ });
1560
+
1561
+ await runWithKevReporting(useCase);
1562
+
1563
+ expect(
1564
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1565
+ ).toHaveLength(0);
1566
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1567
+ });
1568
+
1569
+ it('reports an addition sharing the stored watermark date that was not reported before', async () => {
1570
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1571
+ buildScanEnvironment(
1572
+ scannedExampleLibrary,
1573
+ kevCatalogOf([
1574
+ { cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' },
1575
+ { cveID: 'CVE-2024-0002', dateAdded: '2024-01-02' },
1576
+ ]),
1577
+ );
1578
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1579
+ type: 'stored',
1580
+ watermark: {
1581
+ lastReportedDateAdded: '2024-01-02',
1582
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0001'],
1583
+ },
1584
+ });
1585
+
1586
+ await runWithKevReporting(useCase);
1587
+
1588
+ const kevCalls = kevReportCalls(
1589
+ mockIssueRepository.createNewIssue.mock.calls,
1590
+ );
1591
+ expect(kevCalls).toHaveLength(1);
1592
+ expect(kevCalls[0][3]).toContain('CVE-2024-0002');
1593
+ expect(kevCalls[0][3]).not.toContain('CVE-2024-0001');
1594
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1595
+ [
1596
+ {
1597
+ lastReportedDateAdded: '2024-01-02',
1598
+ reportedCveIdsOnLastReportedDateAdded: [
1599
+ 'CVE-2024-0001',
1600
+ 'CVE-2024-0002',
1601
+ ],
1602
+ },
1603
+ ],
1604
+ ]);
1605
+ });
1606
+
1607
+ it('does not select an addition dated strictly before the stored watermark date', async () => {
1608
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1609
+ buildScanEnvironment(
1610
+ scannedExampleLibrary,
1611
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-01' }]),
1612
+ );
1613
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1614
+ type: 'stored',
1615
+ watermark: {
1616
+ lastReportedDateAdded: '2024-01-02',
1617
+ reportedCveIdsOnLastReportedDateAdded: [],
1618
+ },
1619
+ });
1620
+
1621
+ await runWithKevReporting(useCase);
1622
+
1623
+ expect(
1624
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1625
+ ).toHaveLength(0);
1626
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1627
+ });
1628
+
1629
+ it('advances the watermark over an addition that affects no scanned package so it is not weighed again', async () => {
1630
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1631
+ buildScanEnvironment(
1632
+ scannedExampleLibrary,
1633
+ kevCatalogOf([{ cveID: 'CVE-2024-9999', dateAdded: '2024-01-02' }]),
1634
+ );
1635
+
1636
+ await runWithKevReporting(useCase);
1637
+
1638
+ expect(
1639
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1640
+ ).toHaveLength(0);
1641
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1642
+ [
1643
+ {
1644
+ lastReportedDateAdded: '2024-01-02',
1645
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-9999'],
1646
+ },
1647
+ ],
1648
+ ]);
1649
+ });
1650
+
1651
+ it('skips the report and leaves the watermark untouched when the stored watermark is unreadable', async () => {
1652
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1653
+ buildScanEnvironment(
1654
+ scannedExampleLibrary,
1655
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1656
+ );
1657
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1658
+ type: 'unreadable',
1659
+ reason: 'the stored watermark file holds malformed JSON',
1660
+ });
1661
+ const errorSpy = jest
1662
+ .spyOn(console, 'error')
1663
+ .mockImplementation(() => {});
1664
+
1665
+ await runWithKevReporting(useCase);
1666
+
1667
+ expect(
1668
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1669
+ ).toHaveLength(0);
1670
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1671
+ expect(errorSpy).toHaveBeenCalledWith(
1672
+ expect.stringContaining(
1673
+ 'the stored watermark file holds malformed JSON',
1674
+ ),
1675
+ );
1676
+ errorSpy.mockRestore();
1677
+ });
1678
+
1679
+ it('creates the report issue and logs when the watermark write fails, so the same additions are weighed again', async () => {
1680
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1681
+ buildScanEnvironment(
1682
+ scannedExampleLibrary,
1683
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1684
+ );
1685
+ mockKevReportWatermarkRepository.save.mockRejectedValue(
1686
+ new Error('disk is read-only'),
1687
+ );
1688
+ const errorSpy = jest
1689
+ .spyOn(console, 'error')
1690
+ .mockImplementation(() => {});
1691
+
1692
+ await runWithKevReporting(useCase);
1693
+
1694
+ expect(
1695
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1696
+ ).toHaveLength(1);
1697
+ expect(errorSpy).toHaveBeenCalledWith(
1698
+ expect.stringContaining('disk is read-only'),
1699
+ );
1700
+ errorSpy.mockRestore();
1701
+ });
1241
1702
  });
1242
1703
  });