@oneuptime/common 12.0.25 → 12.0.26

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 (834) hide show
  1. package/Models/AnalyticsModels/Index.ts +2 -0
  2. package/Models/AnalyticsModels/ThreatIntelIndicator.ts +413 -0
  3. package/Models/DatabaseModels/AlertSeverity.ts +4 -1
  4. package/Models/DatabaseModels/CephClusterFeed.ts +625 -0
  5. package/Models/DatabaseModels/CloudResourceFeed.ts +625 -0
  6. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +13 -0
  7. package/Models/DatabaseModels/DetectionRule.ts +9 -3
  8. package/Models/DatabaseModels/DockerHostFeed.ts +625 -0
  9. package/Models/DatabaseModels/DockerSwarmClusterFeed.ts +628 -0
  10. package/Models/DatabaseModels/GoogleSecOpsConnection.ts +9 -3
  11. package/Models/DatabaseModels/HostFeed.ts +625 -0
  12. package/Models/DatabaseModels/IncidentSeverity.ts +4 -1
  13. package/Models/DatabaseModels/Index.ts +22 -0
  14. package/Models/DatabaseModels/KubernetesClusterFeed.ts +628 -0
  15. package/Models/DatabaseModels/Monitor.ts +87 -0
  16. package/Models/DatabaseModels/NetworkDevice.ts +96 -5
  17. package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +92 -6
  18. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +301 -31
  19. package/Models/DatabaseModels/NetworkSite.ts +120 -0
  20. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
  21. package/Models/DatabaseModels/PodmanHostFeed.ts +625 -0
  22. package/Models/DatabaseModels/ProxmoxClusterFeed.ts +626 -0
  23. package/Models/DatabaseModels/ScheduledMaintenance.ts +67 -0
  24. package/Models/DatabaseModels/ServiceFeed.ts +625 -0
  25. package/Models/DatabaseModels/TelemetrySourceMap.ts +474 -0
  26. package/Models/DatabaseModels/ThreatIntelFeed.ts +758 -0
  27. package/Models/DatabaseModels/UserOnCallLog.ts +1 -0
  28. package/Models/DatabaseModels/UserTotpAuth.ts +1 -0
  29. package/Models/DatabaseModels/UserWebAuthn.ts +1 -0
  30. package/Server/API/AlertAPI.ts +23 -1
  31. package/Server/API/BaseAPI.ts +14 -3
  32. package/Server/API/IncidentAPI.ts +41 -1
  33. package/Server/API/IncidentEpisodeAPI.ts +23 -1
  34. package/Server/API/MicrosoftTeamsAPI.ts +31 -2
  35. package/Server/API/ScheduledMaintenanceAPI.ts +23 -1
  36. package/Server/API/SlackAPI.ts +48 -0
  37. package/Server/API/TelemetryAPI.ts +167 -42
  38. package/Server/API/UserOnCallLogTimelineAPI.ts +75 -61
  39. package/Server/API/UserTotpAuthAPI.ts +67 -1
  40. package/Server/API/UserWebAuthnAPI.ts +44 -1
  41. package/Server/Infrastructure/Postgres/SchemaMigrations/1787923136162-MigrationName.ts +57 -0
  42. package/Server/Infrastructure/Postgres/SchemaMigrations/1789600000000-AddAutoProvisionedNetworkDeviceMonitors.ts +49 -0
  43. package/Server/Infrastructure/Postgres/SchemaMigrations/1789700000000-AddNetworkScaleIndexes.ts +129 -0
  44. package/Server/Infrastructure/Postgres/SchemaMigrations/1789800000000-WidenSecurityEventLastErrorColumns.ts +54 -0
  45. package/Server/Infrastructure/Postgres/SchemaMigrations/1789900000000-AddNetworkSiteRollupPolicyAndMaintenance.ts +64 -0
  46. package/Server/Infrastructure/Postgres/SchemaMigrations/1790000000000-AddTelemetrySourceMap.ts +64 -0
  47. package/Server/Infrastructure/Postgres/SchemaMigrations/1790000000001-AddSnmpConfigsToNetworkDeviceDiscoveryScan.ts +72 -0
  48. package/Server/Infrastructure/Postgres/SchemaMigrations/1790003445000-AddSnmpEnabledToNetworkDeviceDiscoveryScan.ts +31 -0
  49. package/Server/Infrastructure/Postgres/SchemaMigrations/1790100000000-AddResourceActivityFeeds.ts +489 -0
  50. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +18 -0
  51. package/Server/Middleware/IdentityRateLimit.ts +53 -3
  52. package/Server/Services/AIService.ts +181 -41
  53. package/Server/Services/CephClusterFeedService.ts +99 -0
  54. package/Server/Services/CephClusterLabelRuleEngineService.ts +29 -0
  55. package/Server/Services/CephClusterOwnerRuleEngineService.ts +23 -0
  56. package/Server/Services/CephClusterOwnerTeamService.ts +129 -0
  57. package/Server/Services/CephClusterOwnerUserService.ts +123 -0
  58. package/Server/Services/CephClusterService.ts +223 -2
  59. package/Server/Services/CloudResourceFeedService.ts +99 -0
  60. package/Server/Services/CloudResourceLabelRuleEngineService.ts +30 -0
  61. package/Server/Services/CloudResourceOwnerRuleEngineService.ts +33 -0
  62. package/Server/Services/CloudResourceOwnerTeamService.ts +129 -0
  63. package/Server/Services/CloudResourceOwnerUserService.ts +123 -0
  64. package/Server/Services/CloudResourceService.ts +227 -2
  65. package/Server/Services/DatabaseService.ts +265 -0
  66. package/Server/Services/DockerHostFeedService.ts +99 -0
  67. package/Server/Services/DockerHostLabelRuleEngineService.ts +29 -0
  68. package/Server/Services/DockerHostOwnerRuleEngineService.ts +23 -0
  69. package/Server/Services/DockerHostOwnerTeamService.ts +129 -0
  70. package/Server/Services/DockerHostOwnerUserService.ts +123 -0
  71. package/Server/Services/DockerHostService.ts +227 -2
  72. package/Server/Services/DockerSwarmClusterFeedService.ts +102 -0
  73. package/Server/Services/DockerSwarmClusterLabelRuleEngineService.ts +30 -0
  74. package/Server/Services/DockerSwarmClusterOwnerRuleEngineService.ts +24 -0
  75. package/Server/Services/DockerSwarmClusterOwnerTeamService.ts +133 -0
  76. package/Server/Services/DockerSwarmClusterOwnerUserService.ts +127 -0
  77. package/Server/Services/DockerSwarmClusterService.ts +229 -2
  78. package/Server/Services/EnterpriseLicenseService.ts +15 -7
  79. package/Server/Services/ExceptionAggregationService.ts +12 -2
  80. package/Server/Services/HostFeedService.ts +99 -0
  81. package/Server/Services/HostLabelRuleEngineService.ts +29 -0
  82. package/Server/Services/HostOwnerRuleEngineService.ts +23 -0
  83. package/Server/Services/HostOwnerTeamService.ts +129 -0
  84. package/Server/Services/HostOwnerUserService.ts +123 -0
  85. package/Server/Services/HostService.ts +215 -2
  86. package/Server/Services/IncidentService.ts +11 -0
  87. package/Server/Services/Index.ts +24 -0
  88. package/Server/Services/KubernetesClusterFeedService.ts +99 -0
  89. package/Server/Services/KubernetesClusterLabelRuleEngineService.ts +30 -0
  90. package/Server/Services/KubernetesClusterOwnerRuleEngineService.ts +24 -0
  91. package/Server/Services/KubernetesClusterOwnerTeamService.ts +133 -0
  92. package/Server/Services/KubernetesClusterOwnerUserService.ts +127 -0
  93. package/Server/Services/KubernetesClusterService.ts +231 -2
  94. package/Server/Services/LogAggregationService.ts +23 -280
  95. package/Server/Services/MetricAggregationService.ts +98 -1
  96. package/Server/Services/MonitorService.ts +177 -13
  97. package/Server/Services/MonitorTemplateService.ts +111 -0
  98. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +777 -86
  99. package/Server/Services/NetworkDeviceAutoImportRuleService.ts +199 -16
  100. package/Server/Services/NetworkDeviceDiscoveryScanService.ts +1055 -14
  101. package/Server/Services/NetworkDeviceService.ts +750 -4
  102. package/Server/Services/NetworkInterfaceService.ts +415 -0
  103. package/Server/Services/NetworkSiteService.ts +485 -52
  104. package/Server/Services/PodmanHostFeedService.ts +99 -0
  105. package/Server/Services/PodmanHostLabelRuleEngineService.ts +29 -0
  106. package/Server/Services/PodmanHostOwnerRuleEngineService.ts +23 -0
  107. package/Server/Services/PodmanHostOwnerTeamService.ts +129 -0
  108. package/Server/Services/PodmanHostOwnerUserService.ts +123 -0
  109. package/Server/Services/PodmanHostService.ts +227 -2
  110. package/Server/Services/ProjectService.ts +16 -10
  111. package/Server/Services/ProxmoxClusterFeedService.ts +99 -0
  112. package/Server/Services/ProxmoxClusterLabelRuleEngineService.ts +30 -0
  113. package/Server/Services/ProxmoxClusterOwnerRuleEngineService.ts +24 -0
  114. package/Server/Services/ProxmoxClusterOwnerTeamService.ts +131 -0
  115. package/Server/Services/ProxmoxClusterOwnerUserService.ts +124 -0
  116. package/Server/Services/ProxmoxClusterService.ts +225 -2
  117. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +61 -0
  118. package/Server/Services/ServiceFeedService.ts +99 -0
  119. package/Server/Services/ServiceLabelRuleEngineService.ts +29 -0
  120. package/Server/Services/ServiceOwnerRuleEngineService.ts +26 -0
  121. package/Server/Services/ServiceOwnerTeamService.ts +129 -0
  122. package/Server/Services/ServiceOwnerUserService.ts +123 -0
  123. package/Server/Services/ServiceService.ts +227 -3
  124. package/Server/Services/StatusPageSubscriberService.ts +7 -0
  125. package/Server/Services/TelemetryAttributeService.ts +13 -3
  126. package/Server/Services/TelemetrySourceMapService.ts +307 -0
  127. package/Server/Services/ThreatIntelFeedService.ts +222 -0
  128. package/Server/Services/ThreatIntelIndicatorService.ts +428 -0
  129. package/Server/Services/TraceAggregationService.ts +101 -11
  130. package/Server/Services/UserService.ts +13 -6
  131. package/Server/Services/UserTwoFactorBackupCodeService.ts +127 -0
  132. package/Server/Services/UserWebhookService.ts +11 -2
  133. package/Server/Static/Brand/README.md +38 -0
  134. package/Server/Static/Brand/favicons/apple-touch-icon.png +0 -0
  135. package/Server/Static/Brand/favicons/favicon-16x16.png +0 -0
  136. package/Server/Static/Brand/favicons/favicon-32x32.png +0 -0
  137. package/Server/Static/Brand/favicons/favicon.ico +0 -0
  138. package/Server/Static/Brand/favicons/safari-pinned-tab.svg +31 -0
  139. package/Server/Static/Brand/hou-wb.svg +1 -0
  140. package/Server/Static/Brand/oneuptime-logo.svg +1 -0
  141. package/Server/Static/Brand/ou-wb.svg +1 -0
  142. package/Server/Static/Vendor/README.md +4 -0
  143. package/Server/Types/AnalyticsDatabase/ModelPermission.ts +13 -3
  144. package/Server/Types/Database/AggregateBy.ts +83 -0
  145. package/Server/Types/Database/AggregateResultUtil.ts +126 -0
  146. package/Server/Types/Database/JSONColumnQuery.ts +18 -0
  147. package/Server/Types/Database/QueryHelper.ts +40 -0
  148. package/Server/Types/Database/QueryUtil.ts +26 -0
  149. package/Server/Types/Workflow/Components/API/Utils.ts +10 -1
  150. package/Server/Types/Workflow/Components/JavaScript.ts +8 -0
  151. package/Server/Utils/AI/CodeFix/CodeFixAgentCompletion.ts +9 -0
  152. package/Server/Utils/AI/SRE/InvestigationGrader.ts +18 -0
  153. package/Server/Utils/AnalyticsDatabase/AttributeFilterStatement.ts +422 -0
  154. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +176 -0
  155. package/Server/Utils/DataSource/HttpFetch.ts +18 -0
  156. package/Server/Utils/Database/RelationIdUtil.ts +42 -0
  157. package/Server/Utils/Marketing/MarketingEventUtil.ts +38 -9
  158. package/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.ts +34 -3
  159. package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +9 -1
  160. package/Server/Utils/Monitor/Criteria/SyntheticMonitor.ts +28 -10
  161. package/Server/Utils/Monitor/MonitorAlert.ts +541 -332
  162. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +330 -64
  163. package/Server/Utils/Monitor/MonitorIncident.ts +640 -434
  164. package/Server/Utils/Monitor/MonitorResource.ts +226 -49
  165. package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +46 -3
  166. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +23 -102
  167. package/Server/Utils/Monitor/PerEntityCriteriaFanOut.ts +329 -0
  168. package/Server/Utils/NetworkDevice/DeviceHealthAggregation.ts +427 -0
  169. package/Server/Utils/NetworkSite/NetworkSiteMaintenanceSuppression.ts +310 -0
  170. package/Server/Utils/OnCallNotificationContext.ts +686 -0
  171. package/Server/Utils/PrivateNetworkWebhookConfig.ts +549 -0
  172. package/Server/Utils/ResourceFeed/ResourceFeedUtil.ts +139 -0
  173. package/Server/Utils/Response.ts +11 -1
  174. package/Server/Utils/SSRFProtection.ts +230 -60
  175. package/Server/Utils/SecurityEvent/ConnectorErrorMessage.ts +106 -0
  176. package/Server/Utils/SecurityEvent/DetectionRuleEvaluator.ts +84 -284
  177. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +47 -20
  178. package/Server/Utils/SecurityEvent/SecurityEventAlerting.ts +318 -0
  179. package/Server/Utils/SecurityEvent/ThreatIntel/TaxiiClient.ts +199 -0
  180. package/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelEnricher.ts +252 -0
  181. package/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.ts +739 -0
  182. package/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.ts +533 -0
  183. package/Server/Utils/StartServer.ts +83 -56
  184. package/Server/Utils/StatusPageSubscriberWebhook.ts +3 -0
  185. package/Server/Utils/Telemetry/SourceMapResolver.ts +480 -0
  186. package/Server/Utils/TwoFactorBackupCodeNotification.ts +106 -0
  187. package/Server/Utils/VM/VMAPI.ts +3 -0
  188. package/Server/Utils/VM/VMRunner.ts +27 -1
  189. package/Server/Utils/VendorAssets.ts +42 -0
  190. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +136 -6
  191. package/Server/Views/AcknowledgeUserOnCallNotification.ejs +11 -9
  192. package/Server/Views/Partials/Head.ejs +9 -9
  193. package/Server/Views/Partials/OnCallNotificationDetails.ejs +48 -0
  194. package/Server/Views/ViewMessage.ejs +11 -9
  195. package/Tests/App/Accounts/LoginTwoFactorRecovery.test.tsx +972 -0
  196. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +401 -0
  197. package/Tests/App/Dashboard/DiscoveryScanEditForm.test.tsx +707 -0
  198. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +1908 -18
  199. package/Tests/App/Dashboard/ResourceFeed.test.tsx +230 -0
  200. package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +216 -0
  201. package/Tests/App/Dashboard/TopologyAddToMonitoring.test.tsx +192 -0
  202. package/Tests/App/Dashboard/WorkspaceNotificationRuleConditions.test.tsx +439 -0
  203. package/Tests/Models/DatabaseModels/DateColumnDeserialization.test.ts +182 -0
  204. package/Tests/Models/DatabaseModels/RelationColumnModelTypeCoverage.test.ts +82 -0
  205. package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +398 -0
  206. package/Tests/Models/DiscoveryScanNameColumn.test.ts +151 -16
  207. package/Tests/Models/DiscoveryScanSnmpEnabledColumn.test.ts +593 -0
  208. package/Tests/Models/NetworkDeviceCreateContract.test.ts +102 -0
  209. package/Tests/Server/API/AIGenerationProjectAIToggle.test.ts +607 -0
  210. package/Tests/Server/API/AIGenerationRequestBudget.test.ts +355 -0
  211. package/Tests/Server/API/AIKillSwitchBackstop.test.ts +602 -0
  212. package/Tests/Server/API/BaseAPIUpdateDateColumns.test.ts +154 -0
  213. package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +335 -0
  214. package/Tests/Server/API/UserOnCallLogTimelineAcknowledgePage.test.ts +611 -0
  215. package/Tests/Server/API/UserTotpAuthAPI.test.ts +498 -4
  216. package/Tests/Server/API/UserWebAuthnBackupCodeMinting.test.ts +682 -0
  217. package/Tests/Server/Infrastructure/ExampleDockerfiles.test.ts +595 -0
  218. package/Tests/Server/Services/AIServiceDailyBudget.test.ts +9 -0
  219. package/Tests/Server/Services/AIServiceProjectAccess.test.ts +6 -0
  220. package/Tests/Server/Services/AutoImportScanCredentialSelect.test.ts +18 -0
  221. package/Tests/Server/Services/DatabaseServiceAggregateBy.test.ts +590 -0
  222. package/Tests/Server/Services/DatabaseServiceUpdateWriteRouting.test.ts +12 -0
  223. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +162 -26
  224. package/Tests/Server/Services/EnterpriseLicenseServiceCreate.test.ts +292 -0
  225. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +119 -12
  226. package/Tests/Server/Services/LogAggregationAttributeOperators.test.ts +109 -0
  227. package/Tests/Server/Services/MetricAggregationAttributeOperators.test.ts +170 -0
  228. package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +383 -0
  229. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +997 -3
  230. package/Tests/Server/Services/NetworkDeviceAutoImportRuleService.test.ts +830 -0
  231. package/Tests/Server/Services/NetworkDeviceAutoMonitorLifecycle.test.ts +528 -0
  232. package/Tests/Server/Services/NetworkDeviceDiscoveryScanService.test.ts +3141 -1
  233. package/Tests/Server/Services/NetworkDeviceRegisteredHostnames.test.ts +1023 -0
  234. package/Tests/Server/Services/NetworkDeviceSiteAssignmentRuleSkip.test.ts +728 -0
  235. package/Tests/Server/Services/NetworkInterfaceServiceUpsert.test.ts +795 -0
  236. package/Tests/Server/Services/NetworkSiteRollupPolicyAndMaintenance.test.ts +903 -0
  237. package/Tests/Server/Services/NetworkSiteService.test.ts +165 -24
  238. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +5 -3
  239. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +5 -2
  240. package/Tests/Server/Services/ResourceCreationFeedWrites.test.ts +304 -0
  241. package/Tests/Server/Services/ResourceFeedServices.test.ts +269 -0
  242. package/Tests/Server/Services/ResourceOwnerFeedWrites.test.ts +245 -0
  243. package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +792 -0
  244. package/Tests/Server/Services/TelemetrySourceMapService.test.ts +483 -0
  245. package/Tests/Server/Services/ThreatIntelFeedService.test.ts +351 -0
  246. package/Tests/Server/Services/ThreatIntelIndicatorService.test.ts +366 -0
  247. package/Tests/Server/Services/TraceAggregationAttributeOperators.test.ts +507 -0
  248. package/Tests/Server/Services/UserTwoFactorBackupCodeGenerateIfNone.test.ts +705 -0
  249. package/Tests/Server/Services/WorkspaceNotificationRuleOnCallDutyPolicy.test.ts +306 -0
  250. package/Tests/Server/Types/Database/AggregateResultUtil.test.ts +386 -0
  251. package/Tests/Server/Types/Database/Permissions/SelectRejectionMessageContract.test.ts +424 -0
  252. package/Tests/Server/Types/Database/QueryHelperWildcard.test.ts +142 -0
  253. package/Tests/Server/Types/Workflow/Components/ApiComponentPrivateNetwork.test.ts +163 -0
  254. package/Tests/Server/Types/Workflow/Components/JavaScriptPrivateNetwork.test.ts +124 -0
  255. package/Tests/Server/Utils/AI/CodeFixAgentCompletion.test.ts +5 -0
  256. package/Tests/Server/Utils/AI/InvestigationGrader.test.ts +39 -1
  257. package/Tests/Server/Utils/AnalyticsDatabase/AttributeFilterStatement.test.ts +338 -0
  258. package/Tests/Server/Utils/AnalyticsDatabase/StatementGeneratorWildcard.test.ts +383 -0
  259. package/Tests/Server/Utils/Marketing/EmittedMarketingEvent.ts +23 -0
  260. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +51 -0
  261. package/Tests/Server/Utils/Monitor/Criteria/SyntheticMonitorCriteria.test.ts +259 -0
  262. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPerSeriesFanout.test.ts +557 -0
  263. package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +12 -2
  264. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkLogWritePath.test.ts +404 -0
  265. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +8 -2
  266. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +125 -98
  267. package/Tests/Server/Utils/Monitor/PerEntityCriteriaFanOut.test.ts +444 -0
  268. package/Tests/Server/Utils/Monitor/PerSeriesSecondEntityAlerting.test.ts +1112 -0
  269. package/Tests/Server/Utils/NetworkDevice/DeviceHealthAggregation.test.ts +717 -0
  270. package/Tests/Server/Utils/NetworkDevice/RulePatternValidator.test.ts +78 -0
  271. package/Tests/Server/Utils/NetworkSite/NetworkSiteMaintenanceSuppression.test.ts +363 -0
  272. package/Tests/Server/Utils/OfflineAssetHygiene.test.ts +35 -0
  273. package/Tests/Server/Utils/OnCallNotificationContext.test.ts +908 -0
  274. package/Tests/Server/Utils/PrivateNetworkWebhookConfig.test.ts +423 -0
  275. package/Tests/Server/Utils/ResourceFeed/ResourceFeedUtil.test.ts +191 -0
  276. package/Tests/Server/Utils/SSRFProtectionPrivateNetwork.test.ts +597 -0
  277. package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +819 -0
  278. package/Tests/Server/Utils/SecurityEvent/DetectionRuleEvaluator.test.ts +381 -0
  279. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +417 -0
  280. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +819 -0
  281. package/Tests/Server/Utils/SecurityEvent/SecurityEventAlerting.test.ts +246 -0
  282. package/Tests/Server/Utils/SecurityEvent/ThreatIntel/TaxiiClient.test.ts +282 -0
  283. package/Tests/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelEnricher.test.ts +312 -0
  284. package/Tests/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.test.ts +854 -0
  285. package/Tests/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.test.ts +437 -0
  286. package/Tests/Server/Utils/StartServerErrorStatus.test.ts +225 -0
  287. package/Tests/Server/Utils/StatusPage/MonitorRulePatternValidator.test.ts +62 -0
  288. package/Tests/Server/Utils/StatusPageSubscriberWebhookPrivateNetwork.test.ts +119 -0
  289. package/Tests/Server/Utils/Telemetry/SourceMapResolver.test.ts +542 -0
  290. package/Tests/Server/Utils/TwoFactorBackupCodeNotification.test.ts +272 -0
  291. package/Tests/Server/Utils/VM/VMRunnerPrivateNetworkWiring.test.ts +111 -0
  292. package/Tests/Server/Utils/VendorAssets.test.ts +117 -0
  293. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +101 -4
  294. package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstallStateDiagnostics.test.ts +922 -0
  295. package/Tests/Server/Utils/Workspace/MicrosoftTeamsRosterDiagnosticMessages.test.ts +642 -0
  296. package/Tests/Server/Views/OnCallAcknowledgePage.test.ts +430 -0
  297. package/Tests/Types/API/URLQueryString.test.ts +472 -0
  298. package/Tests/Types/BaseDatabase/Wildcard.test.ts +163 -0
  299. package/Tests/Types/BaseDatabase/WildcardPattern.test.ts +149 -0
  300. package/Tests/Types/Database/DateColumnValue.test.ts +265 -0
  301. package/Tests/Types/DateToIsoStringOrNull.test.ts +67 -0
  302. package/Tests/Types/Log/LogQueryToFilter.test.ts +218 -43
  303. package/Tests/Types/Monitor/KubernetesTemplateGroupByKeys.test.ts +225 -0
  304. package/Tests/Types/Monitor/TemplateGroupByKeys.test.ts +296 -0
  305. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +102 -0
  306. package/Tests/Types/NetworkAutomation/RuleRunResultDescribe.test.ts +145 -0
  307. package/Tests/Types/NetworkSite/SiteHealthRollupPolicy.test.ts +58 -0
  308. package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +15 -0
  309. package/Tests/Types/Telemetry/TelemetrySearchQuery.test.ts +620 -0
  310. package/Tests/Types/WhatsApp/WhatsAppTemplates.test.ts +110 -0
  311. package/Tests/Types/Workspace/NotificationRuleConditionUtil.test.ts +76 -6
  312. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +16 -4
  313. package/Tests/Types/Workspace/NotificationRules/OnCallDutyPolicyConditions.test.ts +669 -0
  314. package/Tests/UI/Components/DictionaryFilterOperatorWildcard.test.ts +175 -0
  315. package/Tests/UI/Components/IconOpticalSize.test.tsx +377 -0
  316. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsChatsCardInstallGuidance.test.tsx +332 -0
  317. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsInstallGuidanceContent.test.tsx +643 -0
  318. package/Tests/Utils/Monitor/InterfaceInventoryUtil.test.ts +824 -0
  319. package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +626 -0
  320. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +718 -0
  321. package/Tests/Utils/NetworkDevice/DeviceHealthStateUtil.test.ts +59 -0
  322. package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +287 -2
  323. package/Tests/Utils/NetworkDiscovery/DiscoveryImportEligibility.test.ts +73 -0
  324. package/Tests/Utils/NetworkDiscovery/PingMonitorBuilder.test.ts +317 -0
  325. package/Tests/Utils/NetworkDiscovery/RescanIntervalUtil.test.ts +262 -0
  326. package/Tests/Utils/NetworkDiscovery/ScanModeUtil.test.ts +474 -0
  327. package/Tests/Utils/NetworkDiscovery/SnmpScanConfigUtil.test.ts +2442 -0
  328. package/Tests/Utils/NetworkSite/SiteMaintenanceUtil.test.ts +188 -0
  329. package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +522 -0
  330. package/Tests/Utils/NetworkSite/SiteUptimeUtil.test.ts +694 -0
  331. package/Tests/Utils/SecurityEvent/ThreatIntel/StixPatternParser.test.ts +281 -0
  332. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +87 -67
  333. package/Types/API/URL.ts +45 -10
  334. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +8 -0
  335. package/Types/BaseDatabase/NotWildcard.ts +86 -0
  336. package/Types/BaseDatabase/Wildcard.ts +89 -0
  337. package/Types/BaseDatabase/WildcardPattern.ts +176 -0
  338. package/Types/Database/DateColumnValue.ts +184 -0
  339. package/Types/Database/TableColumn.ts +40 -5
  340. package/Types/Date.ts +37 -0
  341. package/Types/Email/EmailTemplateType.ts +1 -0
  342. package/Types/JSON.ts +8 -0
  343. package/Types/Log/LogQueryToFilter.ts +167 -129
  344. package/Types/Monitor/DockerAlertTemplates.ts +43 -12
  345. package/Types/Monitor/HostAlertTemplates.ts +31 -3
  346. package/Types/Monitor/KubernetesAlertTemplates.ts +98 -0
  347. package/Types/Monitor/MonitorStep.ts +49 -0
  348. package/Types/Monitor/PodmanAlertTemplates.ts +39 -12
  349. package/Types/Monitor/SnmpMonitor/CdpNeighbor.ts +14 -0
  350. package/Types/Monitor/SnmpMonitor/LldpNeighbor.ts +11 -0
  351. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +11 -4
  352. package/Types/NetworkAutomation/RuleRunResult.ts +191 -2
  353. package/Types/NetworkSite/SiteHealthRollupPolicy.ts +96 -0
  354. package/Types/Permission.ts +411 -0
  355. package/Types/Probe/ProbeApiIngestResponse.ts +42 -0
  356. package/Types/SecurityEvent/ThreatIntelConstants.ts +118 -0
  357. package/Types/SerializableObjectDictionary.ts +8 -0
  358. package/Types/Telemetry/SourceMap.ts +56 -0
  359. package/Types/Telemetry/TelemetrySearchQuery.ts +949 -0
  360. package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +12 -1
  361. package/UI/Components/Dictionary/DictionaryFilterOperator.ts +83 -0
  362. package/UI/Components/Icon/Icon.tsx +28 -1
  363. package/UI/Components/LogsViewer/components/LogSearchBar.tsx +26 -4
  364. package/UI/Components/LogsViewer/components/LogSearchHelp.tsx +34 -16
  365. package/UI/Components/TelemetryViewer/components/TelemetrySearchBar.tsx +15 -2
  366. package/Utils/Metrics/MetricExplorerUrl.ts +77 -15
  367. package/Utils/Monitor/InterfaceInventoryUtil.ts +251 -0
  368. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +333 -0
  369. package/Utils/Monitor/NetworkTopologyUtil.ts +249 -28
  370. package/Utils/NetworkDevice/DeviceHealthStateUtil.ts +14 -6
  371. package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +2 -0
  372. package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +49 -18
  373. package/Utils/NetworkDiscovery/PingMonitorBuilder.ts +215 -0
  374. package/Utils/NetworkDiscovery/RescanIntervalUtil.ts +148 -0
  375. package/Utils/NetworkDiscovery/ScanModeUtil.ts +105 -0
  376. package/Utils/NetworkDiscovery/SnmpScanConfigUtil.ts +827 -0
  377. package/Utils/NetworkSite/SiteMaintenanceUtil.ts +110 -0
  378. package/Utils/NetworkSite/SiteStatusRollupUtil.ts +350 -19
  379. package/Utils/NetworkSite/SiteUptimeUtil.ts +422 -21
  380. package/Utils/SecurityEvent/ThreatIntel/StixPatternParser.ts +383 -0
  381. package/Utils/Telemetry/CrossSignalScope.ts +37 -39
  382. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  383. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  384. package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js +325 -0
  385. package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js.map +1 -0
  386. package/build/dist/Models/DatabaseModels/AlertSeverity.js +4 -1
  387. package/build/dist/Models/DatabaseModels/AlertSeverity.js.map +1 -1
  388. package/build/dist/Models/DatabaseModels/CephClusterFeed.js +648 -0
  389. package/build/dist/Models/DatabaseModels/CephClusterFeed.js.map +1 -0
  390. package/build/dist/Models/DatabaseModels/CloudResourceFeed.js +648 -0
  391. package/build/dist/Models/DatabaseModels/CloudResourceFeed.js.map +1 -0
  392. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +11 -0
  393. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  394. package/build/dist/Models/DatabaseModels/DetectionRule.js +9 -3
  395. package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
  396. package/build/dist/Models/DatabaseModels/DockerHostFeed.js +648 -0
  397. package/build/dist/Models/DatabaseModels/DockerHostFeed.js.map +1 -0
  398. package/build/dist/Models/DatabaseModels/DockerSwarmClusterFeed.js +648 -0
  399. package/build/dist/Models/DatabaseModels/DockerSwarmClusterFeed.js.map +1 -0
  400. package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js +9 -3
  401. package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js.map +1 -1
  402. package/build/dist/Models/DatabaseModels/HostFeed.js +648 -0
  403. package/build/dist/Models/DatabaseModels/HostFeed.js.map +1 -0
  404. package/build/dist/Models/DatabaseModels/IncidentSeverity.js +4 -1
  405. package/build/dist/Models/DatabaseModels/IncidentSeverity.js.map +1 -1
  406. package/build/dist/Models/DatabaseModels/Index.js +22 -0
  407. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  408. package/build/dist/Models/DatabaseModels/KubernetesClusterFeed.js +648 -0
  409. package/build/dist/Models/DatabaseModels/KubernetesClusterFeed.js.map +1 -0
  410. package/build/dist/Models/DatabaseModels/Monitor.js +81 -0
  411. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  412. package/build/dist/Models/DatabaseModels/NetworkDevice.js +103 -7
  413. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  414. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +91 -6
  415. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -1
  416. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +276 -31
  417. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  418. package/build/dist/Models/DatabaseModels/NetworkSite.js +124 -1
  419. package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
  420. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
  421. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  422. package/build/dist/Models/DatabaseModels/PodmanHostFeed.js +648 -0
  423. package/build/dist/Models/DatabaseModels/PodmanHostFeed.js.map +1 -0
  424. package/build/dist/Models/DatabaseModels/ProxmoxClusterFeed.js +648 -0
  425. package/build/dist/Models/DatabaseModels/ProxmoxClusterFeed.js.map +1 -0
  426. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +66 -0
  427. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  428. package/build/dist/Models/DatabaseModels/ServiceFeed.js +648 -0
  429. package/build/dist/Models/DatabaseModels/ServiceFeed.js.map +1 -0
  430. package/build/dist/Models/DatabaseModels/TelemetrySourceMap.js +496 -0
  431. package/build/dist/Models/DatabaseModels/TelemetrySourceMap.js.map +1 -0
  432. package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js +800 -0
  433. package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js.map +1 -0
  434. package/build/dist/Models/DatabaseModels/UserOnCallLog.js +1 -0
  435. package/build/dist/Models/DatabaseModels/UserOnCallLog.js.map +1 -1
  436. package/build/dist/Models/DatabaseModels/UserTotpAuth.js +1 -0
  437. package/build/dist/Models/DatabaseModels/UserTotpAuth.js.map +1 -1
  438. package/build/dist/Models/DatabaseModels/UserWebAuthn.js +1 -0
  439. package/build/dist/Models/DatabaseModels/UserWebAuthn.js.map +1 -1
  440. package/build/dist/Server/API/AlertAPI.js +18 -1
  441. package/build/dist/Server/API/AlertAPI.js.map +1 -1
  442. package/build/dist/Server/API/BaseAPI.js +8 -1
  443. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  444. package/build/dist/Server/API/IncidentAPI.js +35 -1
  445. package/build/dist/Server/API/IncidentAPI.js.map +1 -1
  446. package/build/dist/Server/API/IncidentEpisodeAPI.js +18 -1
  447. package/build/dist/Server/API/IncidentEpisodeAPI.js.map +1 -1
  448. package/build/dist/Server/API/MicrosoftTeamsAPI.js +30 -2
  449. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  450. package/build/dist/Server/API/ScheduledMaintenanceAPI.js +18 -1
  451. package/build/dist/Server/API/ScheduledMaintenanceAPI.js.map +1 -1
  452. package/build/dist/Server/API/SlackAPI.js +45 -0
  453. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  454. package/build/dist/Server/API/TelemetryAPI.js +100 -35
  455. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  456. package/build/dist/Server/API/UserOnCallLogTimelineAPI.js +64 -59
  457. package/build/dist/Server/API/UserOnCallLogTimelineAPI.js.map +1 -1
  458. package/build/dist/Server/API/UserTotpAuthAPI.js +65 -1
  459. package/build/dist/Server/API/UserTotpAuthAPI.js.map +1 -1
  460. package/build/dist/Server/API/UserWebAuthnAPI.js +42 -1
  461. package/build/dist/Server/API/UserWebAuthnAPI.js.map +1 -1
  462. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787923136162-MigrationName.js +26 -0
  463. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787923136162-MigrationName.js.map +1 -0
  464. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789600000000-AddAutoProvisionedNetworkDeviceMonitors.js +22 -0
  465. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789600000000-AddAutoProvisionedNetworkDeviceMonitors.js.map +1 -0
  466. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789700000000-AddNetworkScaleIndexes.js +88 -0
  467. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789700000000-AddNetworkScaleIndexes.js.map +1 -0
  468. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789800000000-WidenSecurityEventLastErrorColumns.js +39 -0
  469. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789800000000-WidenSecurityEventLastErrorColumns.js.map +1 -0
  470. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789900000000-AddNetworkSiteRollupPolicyAndMaintenance.js +34 -0
  471. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789900000000-AddNetworkSiteRollupPolicyAndMaintenance.js.map +1 -0
  472. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790000000000-AddTelemetrySourceMap.js +32 -0
  473. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790000000000-AddTelemetrySourceMap.js.map +1 -0
  474. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790000000001-AddSnmpConfigsToNetworkDeviceDiscoveryScan.js +61 -0
  475. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790000000001-AddSnmpConfigsToNetworkDeviceDiscoveryScan.js.map +1 -0
  476. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790003445000-AddSnmpEnabledToNetworkDeviceDiscoveryScan.js +23 -0
  477. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790003445000-AddSnmpEnabledToNetworkDeviceDiscoveryScan.js.map +1 -0
  478. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790100000000-AddResourceActivityFeeds.js +179 -0
  479. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790100000000-AddResourceActivityFeeds.js.map +1 -0
  480. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +18 -0
  481. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  482. package/build/dist/Server/Middleware/IdentityRateLimit.js +40 -3
  483. package/build/dist/Server/Middleware/IdentityRateLimit.js.map +1 -1
  484. package/build/dist/Server/Services/AIService.js +177 -30
  485. package/build/dist/Server/Services/AIService.js.map +1 -1
  486. package/build/dist/Server/Services/CephClusterFeedService.js +89 -0
  487. package/build/dist/Server/Services/CephClusterFeedService.js.map +1 -0
  488. package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js +27 -3
  489. package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js.map +1 -1
  490. package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js +21 -0
  491. package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js.map +1 -1
  492. package/build/dist/Server/Services/CephClusterOwnerTeamService.js +120 -0
  493. package/build/dist/Server/Services/CephClusterOwnerTeamService.js.map +1 -1
  494. package/build/dist/Server/Services/CephClusterOwnerUserService.js +113 -0
  495. package/build/dist/Server/Services/CephClusterOwnerUserService.js.map +1 -1
  496. package/build/dist/Server/Services/CephClusterService.js +175 -1
  497. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  498. package/build/dist/Server/Services/CloudResourceFeedService.js +89 -0
  499. package/build/dist/Server/Services/CloudResourceFeedService.js.map +1 -0
  500. package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js +27 -3
  501. package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js.map +1 -1
  502. package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js +28 -3
  503. package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js.map +1 -1
  504. package/build/dist/Server/Services/CloudResourceOwnerTeamService.js +120 -0
  505. package/build/dist/Server/Services/CloudResourceOwnerTeamService.js.map +1 -1
  506. package/build/dist/Server/Services/CloudResourceOwnerUserService.js +113 -0
  507. package/build/dist/Server/Services/CloudResourceOwnerUserService.js.map +1 -1
  508. package/build/dist/Server/Services/CloudResourceService.js +177 -1
  509. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  510. package/build/dist/Server/Services/DatabaseService.js +201 -0
  511. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  512. package/build/dist/Server/Services/DockerHostFeedService.js +89 -0
  513. package/build/dist/Server/Services/DockerHostFeedService.js.map +1 -0
  514. package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js +27 -3
  515. package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js.map +1 -1
  516. package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js +21 -0
  517. package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js.map +1 -1
  518. package/build/dist/Server/Services/DockerHostOwnerTeamService.js +120 -0
  519. package/build/dist/Server/Services/DockerHostOwnerTeamService.js.map +1 -1
  520. package/build/dist/Server/Services/DockerHostOwnerUserService.js +113 -0
  521. package/build/dist/Server/Services/DockerHostOwnerUserService.js.map +1 -1
  522. package/build/dist/Server/Services/DockerHostService.js +177 -1
  523. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  524. package/build/dist/Server/Services/DockerSwarmClusterFeedService.js +90 -0
  525. package/build/dist/Server/Services/DockerSwarmClusterFeedService.js.map +1 -0
  526. package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js +27 -3
  527. package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js.map +1 -1
  528. package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js +21 -0
  529. package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js.map +1 -1
  530. package/build/dist/Server/Services/DockerSwarmClusterOwnerTeamService.js +120 -0
  531. package/build/dist/Server/Services/DockerSwarmClusterOwnerTeamService.js.map +1 -1
  532. package/build/dist/Server/Services/DockerSwarmClusterOwnerUserService.js +113 -0
  533. package/build/dist/Server/Services/DockerSwarmClusterOwnerUserService.js.map +1 -1
  534. package/build/dist/Server/Services/DockerSwarmClusterService.js +175 -1
  535. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  536. package/build/dist/Server/Services/EnterpriseLicenseService.js +40 -30
  537. package/build/dist/Server/Services/EnterpriseLicenseService.js.map +1 -1
  538. package/build/dist/Server/Services/ExceptionAggregationService.js +8 -2
  539. package/build/dist/Server/Services/ExceptionAggregationService.js.map +1 -1
  540. package/build/dist/Server/Services/HostFeedService.js +89 -0
  541. package/build/dist/Server/Services/HostFeedService.js.map +1 -0
  542. package/build/dist/Server/Services/HostLabelRuleEngineService.js +27 -3
  543. package/build/dist/Server/Services/HostLabelRuleEngineService.js.map +1 -1
  544. package/build/dist/Server/Services/HostOwnerRuleEngineService.js +21 -0
  545. package/build/dist/Server/Services/HostOwnerRuleEngineService.js.map +1 -1
  546. package/build/dist/Server/Services/HostOwnerTeamService.js +120 -0
  547. package/build/dist/Server/Services/HostOwnerTeamService.js.map +1 -1
  548. package/build/dist/Server/Services/HostOwnerUserService.js +113 -0
  549. package/build/dist/Server/Services/HostOwnerUserService.js.map +1 -1
  550. package/build/dist/Server/Services/HostService.js +175 -1
  551. package/build/dist/Server/Services/HostService.js.map +1 -1
  552. package/build/dist/Server/Services/IncidentService.js +10 -0
  553. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  554. package/build/dist/Server/Services/Index.js +24 -0
  555. package/build/dist/Server/Services/Index.js.map +1 -1
  556. package/build/dist/Server/Services/KubernetesClusterFeedService.js +89 -0
  557. package/build/dist/Server/Services/KubernetesClusterFeedService.js.map +1 -0
  558. package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js +27 -3
  559. package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js.map +1 -1
  560. package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js +21 -0
  561. package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js.map +1 -1
  562. package/build/dist/Server/Services/KubernetesClusterOwnerTeamService.js +120 -0
  563. package/build/dist/Server/Services/KubernetesClusterOwnerTeamService.js.map +1 -1
  564. package/build/dist/Server/Services/KubernetesClusterOwnerUserService.js +113 -0
  565. package/build/dist/Server/Services/KubernetesClusterOwnerUserService.js.map +1 -1
  566. package/build/dist/Server/Services/KubernetesClusterService.js +177 -1
  567. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  568. package/build/dist/Server/Services/LogAggregationService.js +19 -196
  569. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  570. package/build/dist/Server/Services/MetricAggregationService.js +59 -0
  571. package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
  572. package/build/dist/Server/Services/MonitorService.js +115 -13
  573. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  574. package/build/dist/Server/Services/MonitorTemplateService.js +86 -0
  575. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  576. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +561 -67
  577. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  578. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +130 -16
  579. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -1
  580. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js +850 -8
  581. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js.map +1 -1
  582. package/build/dist/Server/Services/NetworkDeviceService.js +594 -5
  583. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  584. package/build/dist/Server/Services/NetworkInterfaceService.js +306 -0
  585. package/build/dist/Server/Services/NetworkInterfaceService.js.map +1 -1
  586. package/build/dist/Server/Services/NetworkSiteService.js +408 -47
  587. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  588. package/build/dist/Server/Services/PodmanHostFeedService.js +89 -0
  589. package/build/dist/Server/Services/PodmanHostFeedService.js.map +1 -0
  590. package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js +27 -3
  591. package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js.map +1 -1
  592. package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js +21 -0
  593. package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js.map +1 -1
  594. package/build/dist/Server/Services/PodmanHostOwnerTeamService.js +120 -0
  595. package/build/dist/Server/Services/PodmanHostOwnerTeamService.js.map +1 -1
  596. package/build/dist/Server/Services/PodmanHostOwnerUserService.js +113 -0
  597. package/build/dist/Server/Services/PodmanHostOwnerUserService.js.map +1 -1
  598. package/build/dist/Server/Services/PodmanHostService.js +177 -1
  599. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  600. package/build/dist/Server/Services/ProjectService.js +40 -30
  601. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  602. package/build/dist/Server/Services/ProxmoxClusterFeedService.js +89 -0
  603. package/build/dist/Server/Services/ProxmoxClusterFeedService.js.map +1 -0
  604. package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js +27 -3
  605. package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js.map +1 -1
  606. package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js +21 -0
  607. package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js.map +1 -1
  608. package/build/dist/Server/Services/ProxmoxClusterOwnerTeamService.js +120 -0
  609. package/build/dist/Server/Services/ProxmoxClusterOwnerTeamService.js.map +1 -1
  610. package/build/dist/Server/Services/ProxmoxClusterOwnerUserService.js +113 -0
  611. package/build/dist/Server/Services/ProxmoxClusterOwnerUserService.js.map +1 -1
  612. package/build/dist/Server/Services/ProxmoxClusterService.js +175 -1
  613. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  614. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +56 -0
  615. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  616. package/build/dist/Server/Services/ServiceFeedService.js +89 -0
  617. package/build/dist/Server/Services/ServiceFeedService.js.map +1 -0
  618. package/build/dist/Server/Services/ServiceLabelRuleEngineService.js +27 -3
  619. package/build/dist/Server/Services/ServiceLabelRuleEngineService.js.map +1 -1
  620. package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js +24 -0
  621. package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js.map +1 -1
  622. package/build/dist/Server/Services/ServiceOwnerTeamService.js +120 -0
  623. package/build/dist/Server/Services/ServiceOwnerTeamService.js.map +1 -1
  624. package/build/dist/Server/Services/ServiceOwnerUserService.js +113 -0
  625. package/build/dist/Server/Services/ServiceOwnerUserService.js.map +1 -1
  626. package/build/dist/Server/Services/ServiceService.js +173 -2
  627. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  628. package/build/dist/Server/Services/StatusPageSubscriberService.js +7 -0
  629. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  630. package/build/dist/Server/Services/TelemetryAttributeService.js +9 -3
  631. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  632. package/build/dist/Server/Services/TelemetrySourceMapService.js +265 -0
  633. package/build/dist/Server/Services/TelemetrySourceMapService.js.map +1 -0
  634. package/build/dist/Server/Services/ThreatIntelFeedService.js +157 -0
  635. package/build/dist/Server/Services/ThreatIntelFeedService.js.map +1 -0
  636. package/build/dist/Server/Services/ThreatIntelIndicatorService.js +276 -0
  637. package/build/dist/Server/Services/ThreatIntelIndicatorService.js.map +1 -0
  638. package/build/dist/Server/Services/TraceAggregationService.js +62 -6
  639. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  640. package/build/dist/Server/Services/UserService.js +22 -13
  641. package/build/dist/Server/Services/UserService.js.map +1 -1
  642. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +120 -0
  643. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -1
  644. package/build/dist/Server/Services/UserWebhookService.js +11 -2
  645. package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
  646. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +12 -1
  647. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  648. package/build/dist/Server/Types/Database/AggregateBy.js +2 -0
  649. package/build/dist/Server/Types/Database/AggregateBy.js.map +1 -0
  650. package/build/dist/Server/Types/Database/AggregateResultUtil.js +90 -0
  651. package/build/dist/Server/Types/Database/AggregateResultUtil.js.map +1 -0
  652. package/build/dist/Server/Types/Database/JSONColumnQuery.js +14 -0
  653. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  654. package/build/dist/Server/Types/Database/QueryHelper.js +42 -0
  655. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  656. package/build/dist/Server/Types/Database/QueryUtil.js +18 -0
  657. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  658. package/build/dist/Server/Types/Workflow/Components/API/Utils.js +10 -1
  659. package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
  660. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +8 -0
  661. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  662. package/build/dist/Server/Utils/AI/CodeFix/CodeFixAgentCompletion.js +8 -0
  663. package/build/dist/Server/Utils/AI/CodeFix/CodeFixAgentCompletion.js.map +1 -1
  664. package/build/dist/Server/Utils/AI/SRE/InvestigationGrader.js +15 -0
  665. package/build/dist/Server/Utils/AI/SRE/InvestigationGrader.js.map +1 -1
  666. package/build/dist/Server/Utils/AnalyticsDatabase/AttributeFilterStatement.js +266 -0
  667. package/build/dist/Server/Utils/AnalyticsDatabase/AttributeFilterStatement.js.map +1 -0
  668. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +134 -0
  669. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  670. package/build/dist/Server/Utils/DataSource/HttpFetch.js +8 -0
  671. package/build/dist/Server/Utils/DataSource/HttpFetch.js.map +1 -1
  672. package/build/dist/Server/Utils/Database/RelationIdUtil.js +31 -0
  673. package/build/dist/Server/Utils/Database/RelationIdUtil.js.map +1 -1
  674. package/build/dist/Server/Utils/Marketing/MarketingEventUtil.js +26 -8
  675. package/build/dist/Server/Utils/Marketing/MarketingEventUtil.js.map +1 -1
  676. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js +42 -17
  677. package/build/dist/Server/Utils/Monitor/Criteria/ServerMonitorCriteria.js.map +1 -1
  678. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +9 -1
  679. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
  680. package/build/dist/Server/Utils/Monitor/Criteria/SyntheticMonitor.js +15 -2
  681. package/build/dist/Server/Utils/Monitor/Criteria/SyntheticMonitor.js.map +1 -1
  682. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +386 -271
  683. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  684. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +278 -64
  685. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  686. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +469 -350
  687. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  688. package/build/dist/Server/Utils/Monitor/MonitorResource.js +183 -44
  689. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  690. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +46 -3
  691. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -1
  692. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +18 -91
  693. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  694. package/build/dist/Server/Utils/Monitor/PerEntityCriteriaFanOut.js +194 -0
  695. package/build/dist/Server/Utils/Monitor/PerEntityCriteriaFanOut.js.map +1 -0
  696. package/build/dist/Server/Utils/NetworkDevice/DeviceHealthAggregation.js +332 -0
  697. package/build/dist/Server/Utils/NetworkDevice/DeviceHealthAggregation.js.map +1 -0
  698. package/build/dist/Server/Utils/NetworkSite/NetworkSiteMaintenanceSuppression.js +279 -0
  699. package/build/dist/Server/Utils/NetworkSite/NetworkSiteMaintenanceSuppression.js.map +1 -0
  700. package/build/dist/Server/Utils/OnCallNotificationContext.js +415 -0
  701. package/build/dist/Server/Utils/OnCallNotificationContext.js.map +1 -0
  702. package/build/dist/Server/Utils/PrivateNetworkWebhookConfig.js +424 -0
  703. package/build/dist/Server/Utils/PrivateNetworkWebhookConfig.js.map +1 -0
  704. package/build/dist/Server/Utils/ResourceFeed/ResourceFeedUtil.js +108 -0
  705. package/build/dist/Server/Utils/ResourceFeed/ResourceFeedUtil.js.map +1 -0
  706. package/build/dist/Server/Utils/Response.js +12 -1
  707. package/build/dist/Server/Utils/Response.js.map +1 -1
  708. package/build/dist/Server/Utils/SSRFProtection.js +160 -58
  709. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  710. package/build/dist/Server/Utils/SecurityEvent/ConnectorErrorMessage.js +89 -0
  711. package/build/dist/Server/Utils/SecurityEvent/ConnectorErrorMessage.js.map +1 -0
  712. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js +75 -234
  713. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js.map +1 -1
  714. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +41 -16
  715. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
  716. package/build/dist/Server/Utils/SecurityEvent/SecurityEventAlerting.js +217 -0
  717. package/build/dist/Server/Utils/SecurityEvent/SecurityEventAlerting.js.map +1 -0
  718. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/TaxiiClient.js +103 -0
  719. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/TaxiiClient.js.map +1 -0
  720. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelEnricher.js +168 -0
  721. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelEnricher.js.map +1 -0
  722. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.js +527 -0
  723. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelFeedPoller.js.map +1 -0
  724. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.js +376 -0
  725. package/build/dist/Server/Utils/SecurityEvent/ThreatIntel/ThreatIntelMatcher.js.map +1 -0
  726. package/build/dist/Server/Utils/StartServer.js +70 -44
  727. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  728. package/build/dist/Server/Utils/StatusPageSubscriberWebhook.js +3 -0
  729. package/build/dist/Server/Utils/StatusPageSubscriberWebhook.js.map +1 -1
  730. package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js +327 -0
  731. package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js.map +1 -0
  732. package/build/dist/Server/Utils/TwoFactorBackupCodeNotification.js +84 -0
  733. package/build/dist/Server/Utils/TwoFactorBackupCodeNotification.js.map +1 -0
  734. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  735. package/build/dist/Server/Utils/VM/VMRunner.js +10 -1
  736. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  737. package/build/dist/Server/Utils/VendorAssets.js +29 -0
  738. package/build/dist/Server/Utils/VendorAssets.js.map +1 -1
  739. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +121 -6
  740. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  741. package/build/dist/Types/API/URL.js +40 -10
  742. package/build/dist/Types/API/URL.js.map +1 -1
  743. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +8 -0
  744. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  745. package/build/dist/Types/BaseDatabase/NotWildcard.js +72 -0
  746. package/build/dist/Types/BaseDatabase/NotWildcard.js.map +1 -0
  747. package/build/dist/Types/BaseDatabase/Wildcard.js +75 -0
  748. package/build/dist/Types/BaseDatabase/Wildcard.js.map +1 -0
  749. package/build/dist/Types/BaseDatabase/WildcardPattern.js +137 -0
  750. package/build/dist/Types/BaseDatabase/WildcardPattern.js.map +1 -0
  751. package/build/dist/Types/Database/DateColumnValue.js +125 -0
  752. package/build/dist/Types/Database/DateColumnValue.js.map +1 -0
  753. package/build/dist/Types/Database/TableColumn.js +18 -2
  754. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  755. package/build/dist/Types/Date.js +31 -0
  756. package/build/dist/Types/Date.js.map +1 -1
  757. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  758. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  759. package/build/dist/Types/JSON.js +2 -0
  760. package/build/dist/Types/JSON.js.map +1 -1
  761. package/build/dist/Types/Log/LogQueryToFilter.js +111 -108
  762. package/build/dist/Types/Log/LogQueryToFilter.js.map +1 -1
  763. package/build/dist/Types/Monitor/DockerAlertTemplates.js +40 -16
  764. package/build/dist/Types/Monitor/DockerAlertTemplates.js.map +1 -1
  765. package/build/dist/Types/Monitor/HostAlertTemplates.js +29 -7
  766. package/build/dist/Types/Monitor/HostAlertTemplates.js.map +1 -1
  767. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +98 -4
  768. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -1
  769. package/build/dist/Types/Monitor/MonitorStep.js +40 -0
  770. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  771. package/build/dist/Types/Monitor/PodmanAlertTemplates.js +36 -16
  772. package/build/dist/Types/Monitor/PodmanAlertTemplates.js.map +1 -1
  773. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +90 -0
  774. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
  775. package/build/dist/Types/NetworkSite/SiteHealthRollupPolicy.js +84 -0
  776. package/build/dist/Types/NetworkSite/SiteHealthRollupPolicy.js.map +1 -0
  777. package/build/dist/Types/Permission.js +354 -0
  778. package/build/dist/Types/Permission.js.map +1 -1
  779. package/build/dist/Types/SecurityEvent/ThreatIntelConstants.js +100 -0
  780. package/build/dist/Types/SecurityEvent/ThreatIntelConstants.js.map +1 -0
  781. package/build/dist/Types/SerializableObjectDictionary.js +8 -0
  782. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  783. package/build/dist/Types/Telemetry/SourceMap.js +9 -0
  784. package/build/dist/Types/Telemetry/SourceMap.js.map +1 -0
  785. package/build/dist/Types/Telemetry/TelemetrySearchQuery.js +661 -0
  786. package/build/dist/Types/Telemetry/TelemetrySearchQuery.js.map +1 -0
  787. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +12 -1
  788. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
  789. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js +67 -0
  790. package/build/dist/UI/Components/Dictionary/DictionaryFilterOperator.js.map +1 -1
  791. package/build/dist/UI/Components/Icon/Icon.js +28 -1
  792. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  793. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js +25 -4
  794. package/build/dist/UI/Components/LogsViewer/components/LogSearchBar.js.map +1 -1
  795. package/build/dist/UI/Components/LogsViewer/components/LogSearchHelp.js +36 -16
  796. package/build/dist/UI/Components/LogsViewer/components/LogSearchHelp.js.map +1 -1
  797. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js +14 -2
  798. package/build/dist/UI/Components/TelemetryViewer/components/TelemetrySearchBar.js.map +1 -1
  799. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +54 -9
  800. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  801. package/build/dist/Utils/Monitor/InterfaceInventoryUtil.js +128 -0
  802. package/build/dist/Utils/Monitor/InterfaceInventoryUtil.js.map +1 -0
  803. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +190 -0
  804. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -0
  805. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +206 -28
  806. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  807. package/build/dist/Utils/NetworkDevice/DeviceHealthStateUtil.js +14 -7
  808. package/build/dist/Utils/NetworkDevice/DeviceHealthStateUtil.js.map +1 -1
  809. package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -1
  810. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +34 -18
  811. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -1
  812. package/build/dist/Utils/NetworkDiscovery/PingMonitorBuilder.js +163 -0
  813. package/build/dist/Utils/NetworkDiscovery/PingMonitorBuilder.js.map +1 -0
  814. package/build/dist/Utils/NetworkDiscovery/RescanIntervalUtil.js +99 -0
  815. package/build/dist/Utils/NetworkDiscovery/RescanIntervalUtil.js.map +1 -0
  816. package/build/dist/Utils/NetworkDiscovery/ScanModeUtil.js +85 -0
  817. package/build/dist/Utils/NetworkDiscovery/ScanModeUtil.js.map +1 -0
  818. package/build/dist/Utils/NetworkDiscovery/SnmpScanConfigUtil.js +560 -0
  819. package/build/dist/Utils/NetworkDiscovery/SnmpScanConfigUtil.js.map +1 -0
  820. package/build/dist/Utils/NetworkSite/SiteMaintenanceUtil.js +56 -0
  821. package/build/dist/Utils/NetworkSite/SiteMaintenanceUtil.js.map +1 -0
  822. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +185 -10
  823. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
  824. package/build/dist/Utils/NetworkSite/SiteUptimeUtil.js +248 -21
  825. package/build/dist/Utils/NetworkSite/SiteUptimeUtil.js.map +1 -1
  826. package/build/dist/Utils/SecurityEvent/ThreatIntel/StixPatternParser.js +255 -0
  827. package/build/dist/Utils/SecurityEvent/ThreatIntel/StixPatternParser.js.map +1 -0
  828. package/build/dist/Utils/Telemetry/CrossSignalScope.js +27 -30
  829. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  830. package/package.json +2 -1
  831. package/Tests/Types/Log/LogQueryParser.test.ts +0 -186
  832. package/Types/Log/LogQueryParser.ts +0 -252
  833. package/build/dist/Types/Log/LogQueryParser.js +0 -200
  834. package/build/dist/Types/Log/LogQueryParser.js.map +0 -1
@@ -1,12 +1,27 @@
1
1
  import NetworkDeviceDiscoveryScanService from "../../../Server/Services/NetworkDeviceDiscoveryScanService";
2
2
  import NetworkDeviceDiscoveryScan from "../../../Models/DatabaseModels/NetworkDeviceDiscoveryScan";
3
+ import Probe from "../../../Models/DatabaseModels/Probe";
4
+ import ProbeService from "../../../Server/Services/ProbeService";
3
5
  import BadDataException from "../../../Types/Exception/BadDataException";
4
6
  import ObjectID from "../../../Types/ObjectID";
5
7
  import ScanTargetUtil from "../../../Utils/NetworkDiscovery/ScanTargetUtil";
6
8
  import ScanNameUtil from "../../../Utils/NetworkDiscovery/ScanNameUtil";
9
+ import SnmpScanConfigUtil, {
10
+ DiscoveryScanSnmpConfig,
11
+ LEGACY_SNMP_CONFIG_ID,
12
+ MAX_SNMP_CONFIGS_PER_SCAN,
13
+ } from "../../../Utils/NetworkDiscovery/SnmpScanConfigUtil";
7
14
  import CreateBy from "../../../Server/Types/Database/CreateBy";
8
15
  import UpdateBy from "../../../Server/Types/Database/UpdateBy";
9
- import { describe, expect, it } from "@jest/globals";
16
+ import OneUptimeDate from "../../../Types/Date";
17
+ import {
18
+ afterEach,
19
+ beforeEach,
20
+ describe,
21
+ expect,
22
+ it,
23
+ jest,
24
+ } from "@jest/globals";
10
25
 
11
26
  /*
12
27
  * Contract under test: a discovery scan cannot be saved with a target the
@@ -454,3 +469,3128 @@ describe("NetworkDeviceDiscoveryScanService name validation on update", () => {
454
469
  );
455
470
  });
456
471
  });
472
+
473
+ /*
474
+ * ---------------------------------------------------------------------------
475
+ * Editing a scan's settings after creation (OneUptime issue #3444).
476
+ * ---------------------------------------------------------------------------
477
+ *
478
+ * The hooks stopped being pure the moment a scan's target and credentials
479
+ * became editable: deciding whether a save actually CHANGED the sweep means
480
+ * reading the row it is about to overwrite. So from here down the service's
481
+ * two database calls are stubbed — `findBy` hands back the stored row, and
482
+ * `updateColumnsByIdWithoutHooks` records the reconciling write instead of
483
+ * making it — and the assertions are about what the hooks decide, not about
484
+ * Postgres.
485
+ */
486
+
487
+ const SCAN_ID: ObjectID = new ObjectID("33333333-3333-4333-8333-333333333333");
488
+ const OTHER_PROBE_ID: ObjectID = new ObjectID(
489
+ "44444444-4444-4444-8444-444444444444",
490
+ );
491
+
492
+ interface ReconcileWrite {
493
+ id: string;
494
+ data: Record<string, unknown>;
495
+ }
496
+
497
+ interface StoredScanOverrides {
498
+ status?: string;
499
+ cidr?: string;
500
+ probeId?: ObjectID;
501
+ /*
502
+ * The scan's method (issue #3445).
503
+ *
504
+ * Read on exactly ONE of the two reads this fixture stands in for, and the
505
+ * asymmetry is the service's, not the fixture's: the PRE-image
506
+ * (onBeforeUpdate's findBy) selects the sweep columns but not this one, while
507
+ * the post-write read (onUpdateSuccess's findOneById) selects it, because the
508
+ * credential-list rebuild has to know whether the scan sends SNMP at all. So
509
+ * setting this here changes what the rebuild sees and nothing about what the
510
+ * change comparison sees.
511
+ */
512
+ isSnmpEnabled?: boolean;
513
+ snmpConfigs?: Array<DiscoveryScanSnmpConfig> | null | undefined;
514
+ snmpVersion?: string | undefined;
515
+ snmpCommunityString?: string | null | undefined;
516
+ snmpPort?: number | undefined;
517
+ snmpV3Username?: string | null | undefined;
518
+ isRecurring?: boolean;
519
+ rescanIntervalInMinutes?: number | null | undefined;
520
+ completedAt?: Date | null | undefined;
521
+ nextScanAt?: Date | null | undefined;
522
+ }
523
+
524
+ /*
525
+ * The stored row's credential list, in the EXACT shape the flattened columns
526
+ * below it mirror to.
527
+ *
528
+ * That correspondence is the whole point of the fixture, not incidental
529
+ * tidiness. `unchangedSave()` posts this list straight back, so the "does
530
+ * nothing at all when the whole form is re-posted unchanged" test only proves
531
+ * anything if the list the form sends and the list the row holds are the same
532
+ * credentials expressed the same way. If they drifted apart, that test would
533
+ * still pass for the wrong reason — every save would look like a credential
534
+ * change, and the assertion that is supposed to catch it would be measuring
535
+ * a fixture bug instead of the hook.
536
+ *
537
+ * The id is `LEGACY_SNMP_CONFIG_ID`, which is what the backfill migration
538
+ * stamped onto every row that predates the column, so this is a real row's
539
+ * shape rather than an invented one. Returned from a function rather than
540
+ * held as a module constant because the hooks normalize the payload IN PLACE
541
+ * and `saveSettings` copies it onto the stored row — a shared array would let
542
+ * one test's write leak into the next test's pre-image.
543
+ */
544
+ function storedSnmpConfigs(): Array<DiscoveryScanSnmpConfig> {
545
+ return [
546
+ {
547
+ id: LEGACY_SNMP_CONFIG_ID,
548
+ name: "Access switches",
549
+ snmpVersion: "V2c",
550
+ snmpCommunityString: "public",
551
+ snmpPort: 161,
552
+ },
553
+ ];
554
+ }
555
+
556
+ /*
557
+ * A scan as it sits in the database: completed, one-time, sweeping a /24 with
558
+ * a v2c community string — held both as the ordered credential list the
559
+ * product writes now and, mirrored from its first entry, in the flattened
560
+ * columns an older probe still reads. Every test below starts from this and
561
+ * changes the one thing it is about.
562
+ */
563
+ function storedScan(
564
+ overrides?: StoredScanOverrides,
565
+ ): NetworkDeviceDiscoveryScan {
566
+ const scan: NetworkDeviceDiscoveryScan = new NetworkDeviceDiscoveryScan();
567
+
568
+ scan._id = SCAN_ID.toString();
569
+ scan.projectId = PROJECT_ID;
570
+ scan.probeId = PROBE_ID;
571
+ scan.cidr = "192.168.1.0/24";
572
+ scan.status = "Completed";
573
+ scan.snmpConfigs = storedSnmpConfigs();
574
+ scan.snmpVersion = "V2c";
575
+ scan.snmpCommunityString = "public";
576
+ scan.snmpPort = 161;
577
+ scan.isRecurring = false;
578
+
579
+ Object.assign(scan, overrides || {});
580
+
581
+ return scan;
582
+ }
583
+
584
+ let storedScans: Array<NetworkDeviceDiscoveryScan> = [];
585
+ // What the probe lookup finds. Null stands in for a probe that is not there.
586
+ let probeOnLookup: Probe | null = null;
587
+ let reconcileWrites: Array<ReconcileWrite> = [];
588
+ let lastFindByArgs: Record<string, unknown> | null = null;
589
+
590
+ beforeEach(() => {
591
+ storedScans = [storedScan()];
592
+ reconcileWrites = [];
593
+ lastFindByArgs = null;
594
+
595
+ /*
596
+ * The probe a scan points at is looked up so it can be checked against the
597
+ * scan's own project. By default it is one of this project's probes.
598
+ */
599
+ const probe: Probe = new Probe();
600
+ probe._id = PROBE_ID.toString();
601
+ probe.projectId = PROJECT_ID;
602
+ probeOnLookup = probe;
603
+
604
+ jest
605
+ .spyOn(
606
+ ProbeService as unknown as {
607
+ findOneById: (args: Record<string, unknown>) => Promise<unknown>;
608
+ },
609
+ "findOneById",
610
+ )
611
+ .mockImplementation(async () => {
612
+ return probeOnLookup;
613
+ });
614
+
615
+ jest
616
+ .spyOn(
617
+ NetworkDeviceDiscoveryScanService as unknown as {
618
+ findBy: (args: Record<string, unknown>) => Promise<unknown>;
619
+ },
620
+ "findBy",
621
+ )
622
+ .mockImplementation(async (args: Record<string, unknown>) => {
623
+ lastFindByArgs = args;
624
+
625
+ return storedScans;
626
+ });
627
+
628
+ /*
629
+ * The row as it stands after the write. onUpdateSuccess re-reads it rather
630
+ * than predicting it from the payload, so the stub hands back the same
631
+ * fixture the pre-image came from — with the update applied, exactly as the
632
+ * database would have applied it.
633
+ *
634
+ * PROJECTED THROUGH THE CALLER'S OWN `select`, which is not fixture
635
+ * pedantry. onUpdateSuccess rebuilds the credential list of a scan saved
636
+ * through the flattened columns alone, and it does that by asking
637
+ * SnmpScanConfigUtil.resolve() for the row's credentials — a reader that
638
+ * returns the row's LIST when it has one and falls back to the flattened
639
+ * columns only when it does not. The rebuild therefore depends entirely on
640
+ * `snmpConfigs` being absent from this read, which is exactly what the
641
+ * select arranges. A stub that handed back the whole fixture would return
642
+ * the OLD list to that reader, the rebuild would write back the very
643
+ * credentials the save replaced, and every test about it would pass while
644
+ * the flattened columns stayed shadowed in production.
645
+ */
646
+ jest
647
+ .spyOn(
648
+ NetworkDeviceDiscoveryScanService as unknown as {
649
+ findOneById: (args: Record<string, unknown>) => Promise<unknown>;
650
+ },
651
+ "findOneById",
652
+ )
653
+ .mockImplementation(async (args: Record<string, unknown>) => {
654
+ const stored: NetworkDeviceDiscoveryScan | undefined = storedScans[0];
655
+
656
+ if (!stored) {
657
+ return null;
658
+ }
659
+
660
+ const select: Record<string, unknown> = (args["select"] || {}) as Record<
661
+ string,
662
+ unknown
663
+ >;
664
+
665
+ const projected: NetworkDeviceDiscoveryScan =
666
+ new NetworkDeviceDiscoveryScan();
667
+
668
+ for (const column of Object.keys(select)) {
669
+ (projected as unknown as Record<string, unknown>)[column] = (
670
+ stored as unknown as Record<string, unknown>
671
+ )[column];
672
+ }
673
+
674
+ return projected;
675
+ });
676
+
677
+ jest
678
+ .spyOn(
679
+ NetworkDeviceDiscoveryScanService as unknown as {
680
+ updateColumnsByIdWithoutHooks: (input: {
681
+ id: ObjectID;
682
+ data: Record<string, unknown>;
683
+ }) => Promise<void>;
684
+ },
685
+ "updateColumnsByIdWithoutHooks",
686
+ )
687
+ .mockImplementation(
688
+ async (input: { id: ObjectID; data: Record<string, unknown> }) => {
689
+ reconcileWrites.push({
690
+ id: input.id.toString(),
691
+ data: input.data,
692
+ });
693
+ },
694
+ );
695
+ });
696
+
697
+ afterEach(() => {
698
+ jest.restoreAllMocks();
699
+ });
700
+
701
+ /*
702
+ * Run one settings save end to end through both hooks, exactly as
703
+ * DatabaseService does: onBeforeUpdate, then the write, then onUpdateSuccess
704
+ * with the ids the write touched.
705
+ */
706
+ async function saveSettings(
707
+ data: Record<string, unknown>,
708
+ options?: { isRoot?: boolean },
709
+ ): Promise<Array<ReconcileWrite>> {
710
+ const updateBy: UpdateBy<NetworkDeviceDiscoveryScan> = {
711
+ query: { _id: SCAN_ID },
712
+ data: data,
713
+ props: options?.isRoot
714
+ ? { isRoot: true }
715
+ : { isRoot: false, tenantId: PROJECT_ID },
716
+ limit: 1,
717
+ skip: 0,
718
+ } as unknown as UpdateBy<NetworkDeviceDiscoveryScan>;
719
+
720
+ const onUpdate: { updateBy: unknown; carryForward: unknown } =
721
+ (await onBeforeUpdate(updateBy)) as {
722
+ updateBy: unknown;
723
+ carryForward: unknown;
724
+ };
725
+
726
+ /*
727
+ * Stand in for the write itself, so the row onUpdateSuccess re-reads is the
728
+ * row the update produced. Values are coerced the way the column types
729
+ * would coerce them, which is the whole reason the hook reads the row back
730
+ * instead of trusting the payload: a Number column posted as "60" is a
731
+ * number by the time anybody reads it again.
732
+ */
733
+ const stored: NetworkDeviceDiscoveryScan | undefined = storedScans[0];
734
+
735
+ if (stored) {
736
+ for (const key of Object.keys(data)) {
737
+ const value: unknown = data[key];
738
+
739
+ if (key === "isRecurring") {
740
+ stored.isRecurring = Boolean(value);
741
+ } else if (key === "rescanIntervalInMinutes") {
742
+ /*
743
+ * Written through a cast because `exactOptionalPropertyTypes` forbids
744
+ * assigning undefined to an optional property, and a cleared interval
745
+ * is exactly that assignment.
746
+ */
747
+ (stored as unknown as Record<string, unknown>)[key] =
748
+ value === null || value === undefined || value === ""
749
+ ? undefined
750
+ : Number(value);
751
+ } else if (key !== "probe") {
752
+ (stored as unknown as Record<string, unknown>)[key] = value;
753
+ }
754
+ }
755
+ }
756
+
757
+ await (NetworkDeviceDiscoveryScanService as any).onUpdateSuccess(onUpdate, [
758
+ SCAN_ID,
759
+ ]);
760
+
761
+ return reconcileWrites;
762
+ }
763
+
764
+ type HasSweepChangedFunction = (
765
+ scan: NetworkDeviceDiscoveryScan,
766
+ data: Record<string, unknown>,
767
+ ) => boolean;
768
+
769
+ /*
770
+ * The "did this save change what the probe sweeps?" comparison, asked on its
771
+ * own rather than through a whole save.
772
+ *
773
+ * Used for exactly one question, and only because that question cannot be
774
+ * reached the other way: an EMPTY credential list is refused by validation
775
+ * (`applySnmpConfigs` throws before any comparison happens), so a save can
776
+ * never carry one down to here. The comparison still has to answer it
777
+ * correctly, because the shapes it must treat as equal — an unset column, a
778
+ * null the hook writes for a cleared list, and an empty array — are decided
779
+ * by the same normalizer, and a normalizer that disagreed with itself would
780
+ * turn a no-op save into a run-retiring one.
781
+ */
782
+ const hasSweepChanged: HasSweepChangedFunction = (
783
+ scan: NetworkDeviceDiscoveryScan,
784
+ data: Record<string, unknown>,
785
+ ): boolean => {
786
+ return (NetworkDeviceDiscoveryScanService as any).hasSweepChanged(
787
+ scan,
788
+ data,
789
+ Object.keys(data),
790
+ );
791
+ };
792
+
793
+ /*
794
+ * Everything the create wizard posts, unchanged from the stored row. This is
795
+ * what the Edit dialog actually sends when the operator opens it and saves
796
+ * without typing: ModelForm posts every field it declares, dirty or not.
797
+ */
798
+ function unchangedSave(): Record<string, unknown> {
799
+ return {
800
+ name: "Region 1100",
801
+ cidr: "192.168.1.0/24",
802
+ probe: { _id: PROBE_ID.toString() },
803
+ /*
804
+ * The SAME list the stored row holds, posted back verbatim — which is
805
+ * exactly what the Edit dialog does, because its only SNMP control seeds
806
+ * itself from this column and ModelForm posts every field it declares
807
+ * whether the operator touched it or not.
808
+ *
809
+ * This entry is the single most load-bearing line in the fixture. If
810
+ * `normalizeSweepValue` ever stopped comparing two structurally-equal
811
+ * lists as equal, every visit to the Edit dialog would end in a save that
812
+ * retired the scan's run and deleted the hosts it had found — silently,
813
+ * with the operator having changed nothing.
814
+ */
815
+ snmpConfigs: storedSnmpConfigs(),
816
+ snmpVersion: "V2c",
817
+ snmpCommunityString: "public",
818
+ snmpPort: 161,
819
+ snmpV3SecurityLevel: "",
820
+ snmpV3Username: "",
821
+ snmpV3AuthProtocol: "",
822
+ snmpV3AuthKey: "",
823
+ snmpV3PrivProtocol: "",
824
+ snmpV3PrivKey: "",
825
+ isRecurring: false,
826
+ rescanIntervalInMinutes: null,
827
+ };
828
+ }
829
+
830
+ describe("NetworkDeviceDiscoveryScanService: editing a scan's settings", () => {
831
+ it("reads nothing for an update that touches neither settings nor schedule", async () => {
832
+ const updateBy: UpdateBy<NetworkDeviceDiscoveryScan> = makeUpdateBy({
833
+ status: "In Progress",
834
+ startedAt: new Date(0),
835
+ statusMessage: null,
836
+ });
837
+
838
+ const result: { updateBy: unknown; carryForward: unknown } =
839
+ (await onBeforeUpdate(updateBy)) as {
840
+ updateBy: unknown;
841
+ carryForward: unknown;
842
+ };
843
+
844
+ expect(lastFindByArgs).toBeNull();
845
+ expect(result.carryForward).toBeNull();
846
+ expect(result.updateBy).toBe(updateBy);
847
+ });
848
+
849
+ it("reads nothing for a rename, so renaming can never re-queue a scan", async () => {
850
+ const writes: Array<ReconcileWrite> = await saveSettings({
851
+ name: "Region 1200",
852
+ });
853
+
854
+ expect(lastFindByArgs).toBeNull();
855
+ expect(writes).toEqual([]);
856
+ });
857
+
858
+ /*
859
+ * The hook runs BEFORE the permission layer scopes the query, and the API
860
+ * hands the service a bare `{_id}`. Reading with that as root and no tenant
861
+ * of our own would answer "does this id exist, and what are its SNMP
862
+ * credentials" for every project on the instance.
863
+ */
864
+ it("scopes its read to the caller's project", async () => {
865
+ await saveSettings({ cidr: "10.0.0.0/24" });
866
+
867
+ expect(lastFindByArgs).not.toBeNull();
868
+ expect((lastFindByArgs as any).query).toEqual({
869
+ _id: SCAN_ID,
870
+ projectId: PROJECT_ID,
871
+ });
872
+ expect((lastFindByArgs as any).props.isRoot).toBe(true);
873
+ });
874
+
875
+ it("leaves a root caller's query alone, since it has no tenant to scope to", async () => {
876
+ await saveSettings({ cidr: "10.0.0.0/24" }, { isRoot: true });
877
+
878
+ expect((lastFindByArgs as any).query).toEqual({ _id: SCAN_ID });
879
+ });
880
+
881
+ it("re-queues the scan and clears its results when the target changes", async () => {
882
+ const writes: Array<ReconcileWrite> = await saveSettings({
883
+ cidr: "10.0.0.0/24",
884
+ });
885
+
886
+ expect(writes).toHaveLength(1);
887
+ expect(writes[0]!.id).toBe(SCAN_ID.toString());
888
+ expect(writes[0]!.data).toEqual({
889
+ status: "Pending",
890
+ startedAt: null,
891
+ completedAt: null,
892
+ nextScanAt: null,
893
+ discoveredDevices: null,
894
+ scannedHostCount: null,
895
+ respondedHostCount: null,
896
+ autoImportProcessedAt: null,
897
+ /*
898
+ * The row explains itself rather than going quiet: the operator saved a
899
+ * change and the results they were looking at disappeared, and the scans
900
+ * list renders this in the cell where those results used to be.
901
+ */
902
+ statusMessage: expect.stringContaining("queued to run again"),
903
+ });
904
+ });
905
+
906
+ /*
907
+ * Every column that decides what the probe sweeps, one at a time. Three of
908
+ * them used to be covered and eight were not, which is exactly the shape of
909
+ * gap that lets a column quietly fall out of the list: nothing fails, the
910
+ * scan simply keeps advertising results from credentials it no longer has.
911
+ */
912
+ it("re-queues the scan when any single setting of the sweep changes", async () => {
913
+ const changes: Record<string, unknown> = {
914
+ cidr: "10.0.0.0/24",
915
+ /*
916
+ * A DIFFERENT list, not a differently-spelled one: the stored config is
917
+ * kept as entry one and a second credential set is appended, which is
918
+ * the commonest real edit ("the core switches are on v3 as well"). The
919
+ * flattened columns therefore mirror to the same values they already
920
+ * hold, so the only thing that changed is the list itself — and if the
921
+ * comparison for this column were the `String(value)` fall-through the
922
+ * others use, both lists would stringify to "[object Object]" and the
923
+ * scan would keep advertising hosts found with credentials it no longer
924
+ * tries.
925
+ */
926
+ snmpConfigs: [
927
+ ...storedSnmpConfigs(),
928
+ {
929
+ id: "core-routers",
930
+ name: "Core routers",
931
+ snmpVersion: "V3",
932
+ snmpV3SecurityLevel: "authPriv",
933
+ snmpV3Username: "netops",
934
+ snmpV3AuthProtocol: "sha",
935
+ snmpV3AuthKey: "auth-secret",
936
+ snmpV3PrivProtocol: "aes",
937
+ snmpV3PrivKey: "priv-secret",
938
+ },
939
+ ],
940
+ snmpVersion: "V3",
941
+ snmpCommunityString: "private",
942
+ snmpPort: 1161,
943
+ snmpV3SecurityLevel: "authPriv",
944
+ snmpV3Username: "netops",
945
+ snmpV3AuthProtocol: "sha",
946
+ snmpV3AuthKey: "auth-secret",
947
+ snmpV3PrivProtocol: "aes",
948
+ snmpV3PrivKey: "priv-secret",
949
+ };
950
+
951
+ for (const column of Object.keys(changes)) {
952
+ reconcileWrites = [];
953
+ storedScans = [storedScan()];
954
+
955
+ const writes: Array<ReconcileWrite> = await saveSettings({
956
+ ...unchangedSave(),
957
+ [column]: changes[column],
958
+ });
959
+
960
+ expect({ column: column, retired: writes.length }).toEqual({
961
+ column: column,
962
+ retired: 1,
963
+ });
964
+ expect(writes[0]!.data["status"]).toBe("Pending");
965
+ }
966
+ });
967
+
968
+ it("does nothing at all when the whole form is re-posted unchanged", async () => {
969
+ const writes: Array<ReconcileWrite> = await saveSettings(unchangedSave());
970
+
971
+ expect(writes).toEqual([]);
972
+ });
973
+
974
+ /*
975
+ * The v3 columns of a v2c scan are NULL in the database and empty strings in
976
+ * the form. Reading those as different values would retire a good result set
977
+ * the first time anybody opened the dialog and pressed Save.
978
+ */
979
+ it("treats an empty box and an unset column as the same setting", async () => {
980
+ storedScans = [
981
+ storedScan({
982
+ snmpCommunityString: null,
983
+ snmpVersion: "V3",
984
+ snmpV3Username: "netops",
985
+ }),
986
+ ];
987
+
988
+ const writes: Array<ReconcileWrite> = await saveSettings({
989
+ ...unchangedSave(),
990
+ snmpVersion: "V3",
991
+ snmpCommunityString: "",
992
+ snmpV3Username: "netops",
993
+ });
994
+
995
+ expect(writes).toEqual([]);
996
+ });
997
+
998
+ /*
999
+ * The same rule for the credential LIST, on the row shape it actually
1000
+ * matters for: a scan created before `snmpConfigs` existed, whose column is
1001
+ * still NULL, edited by an API caller that clears the list rather than
1002
+ * sending one. "No list" and "a list that was cleared" are the same
1003
+ * statement — the scan falls back to its flattened columns either way — so
1004
+ * neither may read as a change and throw away the run.
1005
+ */
1006
+ it("does not re-queue a scan with no stored list when the save clears the list", async () => {
1007
+ storedScans = [storedScan({ snmpConfigs: undefined })];
1008
+
1009
+ const writes: Array<ReconcileWrite> = await saveSettings({
1010
+ ...unchangedSave(),
1011
+ snmpConfigs: null,
1012
+ });
1013
+
1014
+ expect(writes).toEqual([]);
1015
+ });
1016
+
1017
+ /*
1018
+ * And the shape the hooks refuse to let through, asked of the comparison
1019
+ * directly (see `hasSweepChanged` above for why it cannot be asked through
1020
+ * a save). An empty array from a form is the third spelling of "this scan
1021
+ * has no list of its own", and the normalizer folds all three onto the same
1022
+ * answer.
1023
+ */
1024
+ it("reads an empty credential list and an unset column as the same setting", () => {
1025
+ const scanWithNoList: NetworkDeviceDiscoveryScan = storedScan({
1026
+ snmpConfigs: undefined,
1027
+ });
1028
+
1029
+ const emptyShapes: Array<[string, unknown]> = [
1030
+ ["an empty array", []],
1031
+ ["null", null],
1032
+ ["undefined", undefined],
1033
+ ["an empty string", ""],
1034
+ ];
1035
+
1036
+ for (const [label, value] of emptyShapes) {
1037
+ expect({
1038
+ posted: label,
1039
+ changed: hasSweepChanged(scanWithNoList, { snmpConfigs: value }),
1040
+ }).toEqual({ posted: label, changed: false });
1041
+ }
1042
+ });
1043
+
1044
+ /*
1045
+ * The negative control the test above needs to mean anything: the
1046
+ * comparison is not simply answering "no" for every list. A scan that DOES
1047
+ * hold credentials, handed an empty list, has genuinely lost them.
1048
+ */
1049
+ it("reads an emptied credential list as a change on a scan that had one", () => {
1050
+ expect(hasSweepChanged(storedScan(), { snmpConfigs: [] })).toBe(true);
1051
+ });
1052
+
1053
+ /*
1054
+ * A Number field posts its contents as text, so clearing the box sends "" —
1055
+ * and "" into an integer column is a Postgres error, i.e. a bare 500 in
1056
+ * answer to "I do not want a custom port". Easy to reach only now that the
1057
+ * box arrives pre-filled.
1058
+ */
1059
+ it("reads an emptied number box as unset rather than as an empty string", async () => {
1060
+ const writes: Array<ReconcileWrite> = await saveSettings({
1061
+ ...unchangedSave(),
1062
+ snmpPort: "",
1063
+ });
1064
+
1065
+ // Stored as NULL...
1066
+ expect(storedScans[0]!.snmpPort).toBeNull();
1067
+ // ...and it is a real change, so the scan sweeps again.
1068
+ expect(writes).toHaveLength(1);
1069
+ expect(writes[0]!.data["status"]).toBe("Pending");
1070
+ });
1071
+
1072
+ it("reads an emptied interval box as unset too", async () => {
1073
+ const updateBy: UpdateBy<NetworkDeviceDiscoveryScan> = {
1074
+ query: { _id: SCAN_ID },
1075
+ data: { isRecurring: false, rescanIntervalInMinutes: " " },
1076
+ props: { isRoot: false, tenantId: PROJECT_ID },
1077
+ limit: 1,
1078
+ skip: 0,
1079
+ } as unknown as UpdateBy<NetworkDeviceDiscoveryScan>;
1080
+
1081
+ await onBeforeUpdate(updateBy);
1082
+
1083
+ expect(
1084
+ (updateBy.data as unknown as Record<string, unknown>)[
1085
+ "rescanIntervalInMinutes"
1086
+ ],
1087
+ ).toBeNull();
1088
+ });
1089
+
1090
+ it("re-queues when a credential changes", async () => {
1091
+ const writes: Array<ReconcileWrite> = await saveSettings({
1092
+ ...unchangedSave(),
1093
+ snmpCommunityString: "private",
1094
+ });
1095
+
1096
+ expect(writes).toHaveLength(1);
1097
+ expect(writes[0]!.data["status"]).toBe("Pending");
1098
+ });
1099
+
1100
+ it("re-queues when the port changes, even though the form sends it as text", async () => {
1101
+ const writes: Array<ReconcileWrite> = await saveSettings({
1102
+ ...unchangedSave(),
1103
+ snmpPort: "1161",
1104
+ });
1105
+
1106
+ expect(writes).toHaveLength(1);
1107
+ });
1108
+
1109
+ it("does not re-queue when the port is re-sent as text unchanged", async () => {
1110
+ const writes: Array<ReconcileWrite> = await saveSettings({
1111
+ ...unchangedSave(),
1112
+ snmpPort: "161",
1113
+ });
1114
+
1115
+ expect(writes).toEqual([]);
1116
+ });
1117
+
1118
+ /*
1119
+ * The dashboard posts the relation object and server callers post the FK
1120
+ * column. A hook that understood only one spelling would silently ignore
1121
+ * every probe change made through the other.
1122
+ */
1123
+ it("re-queues when the probe changes, posted as a relation", async () => {
1124
+ const writes: Array<ReconcileWrite> = await saveSettings({
1125
+ ...unchangedSave(),
1126
+ probe: { _id: OTHER_PROBE_ID.toString() },
1127
+ });
1128
+
1129
+ expect(writes).toHaveLength(1);
1130
+ });
1131
+
1132
+ it("re-queues when the probe changes, posted as probeId", async () => {
1133
+ const writes: Array<ReconcileWrite> = await saveSettings({
1134
+ probeId: OTHER_PROBE_ID,
1135
+ });
1136
+
1137
+ expect(writes).toHaveLength(1);
1138
+ });
1139
+
1140
+ it("refuses to clear the probe rather than letting the column reject it", async () => {
1141
+ await expect(saveSettings({ probe: null })).rejects.toThrow(
1142
+ BadDataException,
1143
+ );
1144
+ });
1145
+
1146
+ /*
1147
+ * A scan is dispatched by probe id alone: the claim endpoint hands a Pending
1148
+ * scan to whichever probe authenticates as that id, with no project check of
1149
+ * its own, and writes the hosts it reports back onto this row. Pointing a
1150
+ * scan at another project's probe is therefore pointing it at another
1151
+ * project's NETWORK — so the reference is checked wherever it can be
1152
+ * written.
1153
+ */
1154
+ it("refuses a probe that belongs to another project", async () => {
1155
+ const foreignProbe: Probe = new Probe();
1156
+ foreignProbe._id = OTHER_PROBE_ID.toString();
1157
+ foreignProbe.projectId = new ObjectID(
1158
+ "99999999-9999-4999-8999-999999999999",
1159
+ );
1160
+ probeOnLookup = foreignProbe;
1161
+
1162
+ await expect(
1163
+ saveSettings({ probe: { _id: OTHER_PROBE_ID.toString() } }),
1164
+ ).rejects.toThrow(/another project/);
1165
+ });
1166
+
1167
+ // A probe with no project of its own is a global probe: anyone may use it.
1168
+ it("accepts a global probe", async () => {
1169
+ const globalProbe: Probe = new Probe();
1170
+ globalProbe._id = OTHER_PROBE_ID.toString();
1171
+ probeOnLookup = globalProbe;
1172
+
1173
+ const writes: Array<ReconcileWrite> = await saveSettings({
1174
+ probe: { _id: OTHER_PROBE_ID.toString() },
1175
+ });
1176
+
1177
+ expect(writes).toHaveLength(1);
1178
+ });
1179
+
1180
+ it("refuses a probe that does not exist", async () => {
1181
+ probeOnLookup = null;
1182
+
1183
+ await expect(
1184
+ saveSettings({ probe: { _id: OTHER_PROBE_ID.toString() } }),
1185
+ ).rejects.toThrow(BadDataException);
1186
+ });
1187
+
1188
+ /*
1189
+ * A payload pointing the FK column and the relation object at two different
1190
+ * probes must be refused rather than validated against whichever one TypeORM
1191
+ * happens to persist.
1192
+ */
1193
+ it("refuses a payload that names two different probes", async () => {
1194
+ await expect(
1195
+ saveSettings({
1196
+ probeId: PROBE_ID,
1197
+ probe: { _id: OTHER_PROBE_ID.toString() },
1198
+ }),
1199
+ ).rejects.toThrow(BadDataException);
1200
+ });
1201
+
1202
+ it("checks the probe against the SCAN's project, not the caller's tenant", async () => {
1203
+ const otherProjectId: ObjectID = new ObjectID(
1204
+ "99999999-9999-4999-8999-999999999999",
1205
+ );
1206
+
1207
+ // The scan belongs to another project; so does the probe being set.
1208
+ storedScans = [storedScan()];
1209
+ (storedScans[0] as NetworkDeviceDiscoveryScan).projectId = otherProjectId;
1210
+
1211
+ const probeInThatProject: Probe = new Probe();
1212
+ probeInThatProject._id = OTHER_PROBE_ID.toString();
1213
+ probeInThatProject.projectId = otherProjectId;
1214
+ probeOnLookup = probeInThatProject;
1215
+
1216
+ await expect(
1217
+ saveSettings({ probe: { _id: OTHER_PROBE_ID.toString() } }),
1218
+ ).resolves.toBeDefined();
1219
+ });
1220
+
1221
+ it("abandons a run that is still in progress when the target changes", async () => {
1222
+ storedScans = [storedScan({ status: "In Progress" })];
1223
+
1224
+ const writes: Array<ReconcileWrite> = await saveSettings({
1225
+ cidr: "10.0.0.0/24",
1226
+ });
1227
+
1228
+ /*
1229
+ * Back to Pending, and startedAt cleared. The probe is still sweeping the
1230
+ * old target; when it reports, the result endpoint discards the result
1231
+ * precisely because the scan is Pending again.
1232
+ */
1233
+ expect(writes[0]!.data["status"]).toBe("Pending");
1234
+ expect(writes[0]!.data["startedAt"]).toBeNull();
1235
+ });
1236
+
1237
+ /*
1238
+ * The hook plans for every row the update MATCHED, but the write may affect
1239
+ * fewer — a row hard-deleted in between, or a limit. Reconciling a row that
1240
+ * was not written would retire a scan nobody edited.
1241
+ */
1242
+ it("reconciles only the rows the write actually touched", async () => {
1243
+ const otherId: ObjectID = new ObjectID(
1244
+ "55555555-5555-4555-8555-555555555555",
1245
+ );
1246
+
1247
+ const otherScan: NetworkDeviceDiscoveryScan = storedScan();
1248
+ otherScan._id = otherId.toString();
1249
+
1250
+ storedScans = [storedScan(), otherScan];
1251
+
1252
+ const updateBy: UpdateBy<NetworkDeviceDiscoveryScan> = {
1253
+ query: { projectId: PROJECT_ID },
1254
+ data: { cidr: "10.0.0.0/24" },
1255
+ props: { isRoot: false, tenantId: PROJECT_ID },
1256
+ limit: 2,
1257
+ skip: 0,
1258
+ } as unknown as UpdateBy<NetworkDeviceDiscoveryScan>;
1259
+
1260
+ const onUpdate: { updateBy: unknown; carryForward: unknown } =
1261
+ (await onBeforeUpdate(updateBy)) as {
1262
+ updateBy: unknown;
1263
+ carryForward: unknown;
1264
+ };
1265
+
1266
+ // Both rows were planned for...
1267
+ expect(
1268
+ Object.keys(onUpdate.carryForward as Record<string, unknown>).sort(),
1269
+ ).toEqual([SCAN_ID.toString(), otherId.toString()].sort());
1270
+
1271
+ // ...but only one was written.
1272
+ await (NetworkDeviceDiscoveryScanService as any).onUpdateSuccess(onUpdate, [
1273
+ SCAN_ID,
1274
+ ]);
1275
+
1276
+ expect(
1277
+ reconcileWrites.map((write: ReconcileWrite) => {
1278
+ return write.id;
1279
+ }),
1280
+ ).toEqual([SCAN_ID.toString()]);
1281
+ });
1282
+ });
1283
+
1284
+ describe("NetworkDeviceDiscoveryScanService: when the next run is due", () => {
1285
+ const COMPLETED_AT: Date = OneUptimeDate.fromString(
1286
+ "2026-01-01T00:00:00.000Z",
1287
+ );
1288
+
1289
+ /*
1290
+ * The bug this whole derivation exists for. Turning recurrence on for a scan
1291
+ * that had already finished used to write the flag and nothing else — and
1292
+ * the requeue worker matches on `nextScanAt <= now`, which is never true of
1293
+ * NULL. The list said "Every 60 min" over a scan that would never run again.
1294
+ */
1295
+ it("schedules a finished one-time scan the moment recurrence is turned on", async () => {
1296
+ storedScans = [
1297
+ storedScan({ status: "Completed", completedAt: COMPLETED_AT }),
1298
+ ];
1299
+
1300
+ const writes: Array<ReconcileWrite> = await saveSettings({
1301
+ isRecurring: true,
1302
+ rescanIntervalInMinutes: 60,
1303
+ });
1304
+
1305
+ expect(writes).toHaveLength(1);
1306
+ expect(
1307
+ OneUptimeDate.fromString(writes[0]!.data["nextScanAt"] as Date).getTime(),
1308
+ ).toBe(COMPLETED_AT.getTime() + 60 * 60 * 1000);
1309
+ });
1310
+
1311
+ it("re-derives the next run when the interval is shortened", async () => {
1312
+ storedScans = [
1313
+ storedScan({
1314
+ status: "Completed",
1315
+ completedAt: COMPLETED_AT,
1316
+ isRecurring: true,
1317
+ rescanIntervalInMinutes: 1440,
1318
+ nextScanAt: OneUptimeDate.addRemoveMinutes(COMPLETED_AT, 1440),
1319
+ }),
1320
+ ];
1321
+
1322
+ const writes: Array<ReconcileWrite> = await saveSettings({
1323
+ isRecurring: true,
1324
+ rescanIntervalInMinutes: 60,
1325
+ });
1326
+
1327
+ /*
1328
+ * Measured from the last run, not from the save — so a scan whose cadence
1329
+ * was shortened past its own last completion reads as already due, which
1330
+ * is exactly what it is. Before this, the new cadence did not take effect
1331
+ * until one full OLD cadence had elapsed.
1332
+ */
1333
+ expect(writes).toHaveLength(1);
1334
+ expect(
1335
+ OneUptimeDate.fromString(writes[0]!.data["nextScanAt"] as Date).getTime(),
1336
+ ).toBe(COMPLETED_AT.getTime() + 60 * 60 * 1000);
1337
+ });
1338
+
1339
+ /*
1340
+ * The form posts a Number field, and what arrives on the wire is not
1341
+ * guaranteed to be a number. Predicting the post-write schedule from the
1342
+ * payload would read "60" as "no cadence" and quietly unschedule a scan the
1343
+ * operator had just scheduled; the hook reads the stored row back instead,
1344
+ * where the column type has already had its say.
1345
+ */
1346
+ it("schedules from the stored value, not from whatever shape the request sent", async () => {
1347
+ storedScans = [
1348
+ storedScan({ status: "Completed", completedAt: COMPLETED_AT }),
1349
+ ];
1350
+
1351
+ const writes: Array<ReconcileWrite> = await saveSettings({
1352
+ isRecurring: true,
1353
+ rescanIntervalInMinutes: "60",
1354
+ });
1355
+
1356
+ expect(writes).toHaveLength(1);
1357
+ expect(
1358
+ OneUptimeDate.fromString(writes[0]!.data["nextScanAt"] as Date).getTime(),
1359
+ ).toBe(COMPLETED_AT.getTime() + 60 * 60 * 1000);
1360
+ });
1361
+
1362
+ it("clears the next run when recurrence is turned off", async () => {
1363
+ storedScans = [
1364
+ storedScan({
1365
+ status: "Completed",
1366
+ completedAt: COMPLETED_AT,
1367
+ isRecurring: true,
1368
+ rescanIntervalInMinutes: 60,
1369
+ nextScanAt: OneUptimeDate.addRemoveMinutes(COMPLETED_AT, 60),
1370
+ }),
1371
+ ];
1372
+
1373
+ const writes: Array<ReconcileWrite> = await saveSettings({
1374
+ isRecurring: false,
1375
+ rescanIntervalInMinutes: 60,
1376
+ });
1377
+
1378
+ /*
1379
+ * A stale timestamp left behind here is not inert: turning recurrence back
1380
+ * on months later would find it already in the past and fire an immediate,
1381
+ * unasked-for sweep.
1382
+ *
1383
+ * The whole payload is asserted, not just the one key: turning recurrence
1384
+ * off must not touch the run — the scan keeps its results and its status,
1385
+ * and only its schedule changes.
1386
+ */
1387
+ expect(writes).toHaveLength(1);
1388
+ expect(writes[0]!.data).toEqual({ nextScanAt: null });
1389
+ });
1390
+
1391
+ it("writes nothing when the schedule is re-posted unchanged", async () => {
1392
+ storedScans = [
1393
+ storedScan({
1394
+ status: "Completed",
1395
+ completedAt: COMPLETED_AT,
1396
+ isRecurring: true,
1397
+ rescanIntervalInMinutes: 60,
1398
+ nextScanAt: OneUptimeDate.addRemoveMinutes(COMPLETED_AT, 60),
1399
+ }),
1400
+ ];
1401
+
1402
+ const writes: Array<ReconcileWrite> = await saveSettings({
1403
+ isRecurring: true,
1404
+ rescanIntervalInMinutes: 60,
1405
+ });
1406
+
1407
+ expect(writes).toEqual([]);
1408
+ });
1409
+
1410
+ it("clamps a sub-minimum interval instead of scheduling a sweep every minute", async () => {
1411
+ storedScans = [
1412
+ storedScan({ status: "Completed", completedAt: COMPLETED_AT }),
1413
+ ];
1414
+
1415
+ const writes: Array<ReconcileWrite> = await saveSettings({
1416
+ isRecurring: true,
1417
+ rescanIntervalInMinutes: 1,
1418
+ });
1419
+
1420
+ expect(
1421
+ OneUptimeDate.fromString(writes[0]!.data["nextScanAt"] as Date).getTime(),
1422
+ ).toBe(COMPLETED_AT.getTime() + 15 * 60 * 1000);
1423
+ });
1424
+
1425
+ it("schedules nothing for a recurring scan with no interval", async () => {
1426
+ storedScans = [
1427
+ storedScan({ status: "Completed", completedAt: COMPLETED_AT }),
1428
+ ];
1429
+
1430
+ const writes: Array<ReconcileWrite> = await saveSettings({
1431
+ isRecurring: true,
1432
+ rescanIntervalInMinutes: null,
1433
+ });
1434
+
1435
+ expect(writes).toEqual([]);
1436
+ });
1437
+
1438
+ /*
1439
+ * A scan that is queued or mid-sweep has a run coming already; the result
1440
+ * endpoint stamps the one after it. Scheduling from here would race it.
1441
+ */
1442
+ it("schedules nothing while a run is queued or in flight", async () => {
1443
+ for (const status of ["Pending", "In Progress"]) {
1444
+ reconcileWrites = [];
1445
+ storedScans = [storedScan({ status: status, completedAt: null })];
1446
+
1447
+ const writes: Array<ReconcileWrite> = await saveSettings({
1448
+ isRecurring: true,
1449
+ rescanIntervalInMinutes: 60,
1450
+ });
1451
+
1452
+ expect(writes).toEqual([]);
1453
+ }
1454
+ });
1455
+
1456
+ it("schedules a failed run too, so a transient failure does not end the recurrence", async () => {
1457
+ storedScans = [storedScan({ status: "Failed", completedAt: COMPLETED_AT })];
1458
+
1459
+ const writes: Array<ReconcileWrite> = await saveSettings({
1460
+ isRecurring: true,
1461
+ rescanIntervalInMinutes: 60,
1462
+ });
1463
+
1464
+ expect(writes).toHaveLength(1);
1465
+ expect(writes[0]!.data["nextScanAt"]).not.toBeNull();
1466
+ });
1467
+
1468
+ /*
1469
+ * A save that never mentions the schedule must not move it. The stale-scan
1470
+ * reaper deliberately marks a stranded run due IMMEDIATELY rather than one
1471
+ * interval later, and re-deriving on an unrelated save would quietly push
1472
+ * that recovery out by a whole cadence.
1473
+ */
1474
+ it("leaves the schedule alone when the save did not ask about it", async () => {
1475
+ storedScans = [
1476
+ storedScan({
1477
+ status: "Failed",
1478
+ completedAt: COMPLETED_AT,
1479
+ isRecurring: true,
1480
+ rescanIntervalInMinutes: 60,
1481
+ // What the reaper writes: due now, not one interval after the failure.
1482
+ nextScanAt: COMPLETED_AT,
1483
+ }),
1484
+ ];
1485
+
1486
+ const writes: Array<ReconcileWrite> = await saveSettings({
1487
+ cidr: "192.168.1.0/24",
1488
+ snmpCommunityString: "public",
1489
+ });
1490
+
1491
+ /*
1492
+ * This save DOES reconcile something, and deliberately: it was written
1493
+ * through the flattened columns alone, so the credential list is rebuilt
1494
+ * from them (issue #3458 — a null list is readable but not editable, and
1495
+ * the Edit dialog would open a blank card over it).
1496
+ *
1497
+ * What the test is about is what that write must NOT carry. Asserted as
1498
+ * the write's exact key set rather than as `not.toContain("nextScanAt")`,
1499
+ * so a future reconciliation that starts moving the schedule on an
1500
+ * unrelated save fails here instead of passing on a narrower assertion.
1501
+ */
1502
+ for (const write of writes) {
1503
+ expect(Object.keys(write.data)).toEqual(["snmpConfigs"]);
1504
+ }
1505
+ });
1506
+
1507
+ /*
1508
+ * Both at once: the target changed AND the scan is recurring. The re-queue
1509
+ * wins — the run it would have been scheduled from no longer exists, and the
1510
+ * next one is scheduled when the new sweep reports.
1511
+ */
1512
+ it("leaves no next run scheduled when the sweep is re-queued", async () => {
1513
+ storedScans = [
1514
+ storedScan({
1515
+ status: "Completed",
1516
+ completedAt: COMPLETED_AT,
1517
+ isRecurring: true,
1518
+ rescanIntervalInMinutes: 60,
1519
+ nextScanAt: OneUptimeDate.addRemoveMinutes(COMPLETED_AT, 60),
1520
+ }),
1521
+ ];
1522
+
1523
+ const writes: Array<ReconcileWrite> = await saveSettings({
1524
+ cidr: "10.0.0.0/24",
1525
+ isRecurring: true,
1526
+ rescanIntervalInMinutes: 60,
1527
+ });
1528
+
1529
+ expect(writes).toHaveLength(1);
1530
+ expect(writes[0]!.data["status"]).toBe("Pending");
1531
+ expect(writes[0]!.data["nextScanAt"]).toBeNull();
1532
+ });
1533
+ });
1534
+
1535
+ /*
1536
+ * ---------------------------------------------------------------------------
1537
+ * The ordered SNMP credential list (OneUptime issue #3458).
1538
+ * ---------------------------------------------------------------------------
1539
+ *
1540
+ * A scan used to carry exactly ONE credential set, in the flattened
1541
+ * snmpVersion / snmpCommunityString / snmpPort / snmpV3* columns, and the
1542
+ * probe reused it for every address in the sweep. Real segments are not
1543
+ * shaped that way — access switches on v2c with one community, the core on
1544
+ * v3, printers on the factory default — so such a scan silently missed every
1545
+ * device speaking a version it was not configured for and reported "0
1546
+ * discovered" with no indication that anything had been skipped.
1547
+ *
1548
+ * The `snmpConfigs` column is that list. Everything below pins the four
1549
+ * things the service does with it, each of which is a way the column could
1550
+ * quietly go wrong:
1551
+ *
1552
+ * 1. Every scan the product CREATES ends up with a list, even one posted
1553
+ * through the flattened columns alone. The Edit dialog's only SNMP
1554
+ * control reads this column, so a scan without one would open showing an
1555
+ * empty card over credentials the operator cannot see.
1556
+ * 2. A bad list is refused with the SHARED validator's sentence, as a 400 —
1557
+ * not as the TypeError an unguarded `.map()` over a posted string would
1558
+ * raise, which is a 500 for what is plainly a client mistake.
1559
+ * 3. A stored list is mirrored back onto the flattened columns, for probes
1560
+ * that have never heard of it.
1561
+ * 4. An update written through the flattened columns alone is reconciled
1562
+ * rather than silently ignored.
1563
+ */
1564
+
1565
+ /*
1566
+ * A create payload with a valid target and probe, plus whatever the caller
1567
+ * wants to post on top.
1568
+ *
1569
+ * `extra` is Record<string, unknown> for the same reason `cidr` is unknown at
1570
+ * the top of this file: the hook runs before the model's own type checks and
1571
+ * is the first thing to see the raw request JSON, so a jsonb column really
1572
+ * can arrive holding a string, a number or an object rather than a list.
1573
+ */
1574
+ function makeCreateByWith(
1575
+ extra: Record<string, unknown>,
1576
+ ): CreateBy<NetworkDeviceDiscoveryScan> {
1577
+ const createBy: CreateBy<NetworkDeviceDiscoveryScan> =
1578
+ makeCreateBy("192.168.1.0/24");
1579
+
1580
+ Object.assign(createBy.data as unknown as Record<string, unknown>, extra);
1581
+
1582
+ return createBy;
1583
+ }
1584
+
1585
+ type CreatedRowFunction = (
1586
+ extra: Record<string, unknown>,
1587
+ ) => Promise<Record<string, unknown>>;
1588
+
1589
+ /*
1590
+ * The row as the create hook would hand it to the database. The hook rewrites
1591
+ * its input in place — the list is validated, normalized and given ids — so
1592
+ * what is asserted on is the payload AFTER the hook, not the one posted.
1593
+ */
1594
+ const createdRow: CreatedRowFunction = async (
1595
+ extra: Record<string, unknown>,
1596
+ ): Promise<Record<string, unknown>> => {
1597
+ const createBy: CreateBy<NetworkDeviceDiscoveryScan> =
1598
+ makeCreateByWith(extra);
1599
+
1600
+ await onBeforeCreate(createBy);
1601
+
1602
+ return createBy.data as unknown as Record<string, unknown>;
1603
+ };
1604
+
1605
+ type UpdatedRowFunction = (
1606
+ data: Record<string, unknown>,
1607
+ ) => Promise<Record<string, unknown>>;
1608
+
1609
+ /*
1610
+ * The same thing for an update: the payload the write would actually carry,
1611
+ * once the hook has normalized the list and mirrored it onto the flattened
1612
+ * columns.
1613
+ */
1614
+ const updatedRow: UpdatedRowFunction = async (
1615
+ data: Record<string, unknown>,
1616
+ ): Promise<Record<string, unknown>> => {
1617
+ await onBeforeUpdate(makeUpdateBy(data));
1618
+
1619
+ return data;
1620
+ };
1621
+
1622
+ type CaptureErrorFunction = (
1623
+ run: () => Promise<unknown>,
1624
+ ) => Promise<Error | null>;
1625
+
1626
+ /*
1627
+ * The error a hook threw, as a value rather than as a matcher.
1628
+ *
1629
+ * `rejects.toThrow` proves that SOMETHING was thrown; the loops below have to
1630
+ * prove two harder things about the same throw at once. That it is a
1631
+ * BadDataException — a 400 — rather than the TypeError an unguarded
1632
+ * `.map()`/`.length` over a posted string or number would raise, which is a
1633
+ * 500 in answer to a client mistake. And that its sentence is the shared
1634
+ * validator's own, so the wizard's inline error and the API's 400 are the
1635
+ * same words rather than two descriptions of the same rule that can drift
1636
+ * apart.
1637
+ *
1638
+ * Returning the error also lets each case name itself in the assertion, which
1639
+ * is what makes a failure inside a table-driven loop readable.
1640
+ */
1641
+ const captureError: CaptureErrorFunction = async (
1642
+ run: () => Promise<unknown>,
1643
+ ): Promise<Error | null> => {
1644
+ try {
1645
+ await run();
1646
+ } catch (error) {
1647
+ return error as Error;
1648
+ }
1649
+
1650
+ return null;
1651
+ };
1652
+
1653
+ /*
1654
+ * One v3 credential set, complete. Used wherever a test needs a config whose
1655
+ * every flattened counterpart has a value, so that "was this mirrored?" can
1656
+ * be asked of all nine columns rather than of the two a v2c config fills in.
1657
+ */
1658
+ function coreRoutersConfig(): DiscoveryScanSnmpConfig {
1659
+ return {
1660
+ id: "core-routers",
1661
+ name: "Core routers",
1662
+ snmpVersion: "V3",
1663
+ snmpPort: 1161,
1664
+ snmpV3SecurityLevel: "authPriv",
1665
+ snmpV3Username: "netops",
1666
+ snmpV3AuthProtocol: "SHA",
1667
+ snmpV3AuthKey: "auth-secret",
1668
+ snmpV3PrivProtocol: "AES",
1669
+ snmpV3PrivKey: "priv-secret",
1670
+ };
1671
+ }
1672
+
1673
+ describe("NetworkDeviceDiscoveryScanService: the credential list on create", () => {
1674
+ /*
1675
+ * The invariant the Edit dialog depends on. That form's only SNMP control
1676
+ * is the list editor, so it selects `snmpConfigs` and nothing else — a scan
1677
+ * whose list were NULL would open with one empty card, and saving it would
1678
+ * replace credentials the operator never saw. The reader still falls back
1679
+ * to the flattened columns, because rows written out of band exist; this
1680
+ * only means the product never creates one.
1681
+ */
1682
+ it("derives a one-entry list from the flattened columns when the payload has none", async () => {
1683
+ const row: Record<string, unknown> = await createdRow({
1684
+ snmpVersion: "V3",
1685
+ snmpCommunityString: "private",
1686
+ snmpPort: 1161,
1687
+ snmpV3SecurityLevel: "authPriv",
1688
+ snmpV3Username: "netops",
1689
+ snmpV3AuthProtocol: "SHA",
1690
+ snmpV3AuthKey: "auth-secret",
1691
+ snmpV3PrivProtocol: "AES",
1692
+ snmpV3PrivKey: "priv-secret",
1693
+ });
1694
+
1695
+ /*
1696
+ * The id is the synthetic "legacy" one, which is the same id the backfill
1697
+ * migration wrote onto every historical row and the same one the reader
1698
+ * synthesizes for a scan with no list — so a scan created this way is
1699
+ * indistinguishable from one that predates the column.
1700
+ */
1701
+ expect(row["snmpConfigs"]).toEqual([
1702
+ {
1703
+ id: LEGACY_SNMP_CONFIG_ID,
1704
+ snmpVersion: "V3",
1705
+ snmpCommunityString: "private",
1706
+ snmpPort: 1161,
1707
+ snmpV3SecurityLevel: "authPriv",
1708
+ snmpV3Username: "netops",
1709
+ snmpV3AuthProtocol: "SHA",
1710
+ snmpV3AuthKey: "auth-secret",
1711
+ snmpV3PrivProtocol: "AES",
1712
+ snmpV3PrivKey: "priv-secret",
1713
+ },
1714
+ ]);
1715
+ });
1716
+
1717
+ /*
1718
+ * The bare case, which is the one an integration written before this column
1719
+ * existed actually posts: a target and a probe and nothing else. The
1720
+ * version defaults to the same v2c the probe would have swept with.
1721
+ */
1722
+ it("gives even a scan created with no SNMP settings at all a list", async () => {
1723
+ const row: Record<string, unknown> = await createdRow({});
1724
+
1725
+ expect(row["snmpConfigs"]).toEqual([
1726
+ { id: LEGACY_SNMP_CONFIG_ID, snmpVersion: "V2c" },
1727
+ ]);
1728
+ });
1729
+
1730
+ it("leaves a supplied list alone rather than deriving over it", async () => {
1731
+ const row: Record<string, unknown> = await createdRow({
1732
+ snmpConfigs: [
1733
+ {
1734
+ id: "access-switches",
1735
+ name: "Access switches",
1736
+ snmpVersion: "V2c",
1737
+ snmpCommunityString: "public",
1738
+ },
1739
+ coreRoutersConfig(),
1740
+ ],
1741
+ });
1742
+
1743
+ const configs: Array<DiscoveryScanSnmpConfig> = row[
1744
+ "snmpConfigs"
1745
+ ] as Array<DiscoveryScanSnmpConfig>;
1746
+
1747
+ /*
1748
+ * Both entries survive, in the order they were posted. Order is not
1749
+ * cosmetic: it is the order the probe tries them in per host, and the
1750
+ * first entry is the one mirrored onto the flattened columns.
1751
+ */
1752
+ expect(
1753
+ configs.map((config: DiscoveryScanSnmpConfig): string | undefined => {
1754
+ return config.id;
1755
+ }),
1756
+ ).toEqual(["access-switches", "core-routers"]);
1757
+ });
1758
+
1759
+ /*
1760
+ * THE CREATE-PATH MIRROR, and a regression test for a bug that shipped
1761
+ * nowhere but got as far as review.
1762
+ *
1763
+ * `createBy.data` is a model INSTANCE, and every column on this model is
1764
+ * declared as a class field with `= undefined` — so `Object.keys()` on a
1765
+ * freshly built scan returns all thirty-seven column names no matter what
1766
+ * the client posted. The hook originally handed that straight to the mirror,
1767
+ * whose "the payload wins where it speaks for itself" guard then fired for
1768
+ * all nine flattened columns on every create, and the mirror became a
1769
+ * permanent no-op on the one path that needs it most.
1770
+ *
1771
+ * What that cost: the wizard's SNMP step posts `snmpConfigs` and nothing
1772
+ * else, so a scan created through the product stored a v3 credential set in
1773
+ * the list while its flattened columns kept the bare Postgres defaults —
1774
+ * and every probe running a version that predates the list swept it with
1775
+ * v2c/"public"/161 and reported the confident zero the whole feature exists
1776
+ * to stop. Invisible in the UI, invisible in the row, visible only as a
1777
+ * subnet that finds nothing.
1778
+ *
1779
+ * The fix is to pass the keys the client actually SUPPLIED (a posted column
1780
+ * carries a defined value; an untouched one does not), which is the
1781
+ * distinction the guard wanted in the first place. This test is written
1782
+ * against the observable behaviour rather than the key list, so it stays
1783
+ * true whatever shape the payload arrives in.
1784
+ */
1785
+ it("mirrors the first config onto the flattened columns, for probes that predate the list", async () => {
1786
+ const row: Record<string, unknown> = await createdRow({
1787
+ snmpConfigs: [coreRoutersConfig(), ...storedSnmpConfigs()],
1788
+ });
1789
+
1790
+ expect({
1791
+ snmpVersion: row["snmpVersion"],
1792
+ snmpCommunityString: row["snmpCommunityString"],
1793
+ snmpPort: row["snmpPort"],
1794
+ snmpV3SecurityLevel: row["snmpV3SecurityLevel"],
1795
+ snmpV3Username: row["snmpV3Username"],
1796
+ snmpV3AuthProtocol: row["snmpV3AuthProtocol"],
1797
+ snmpV3AuthKey: row["snmpV3AuthKey"],
1798
+ snmpV3PrivProtocol: row["snmpV3PrivProtocol"],
1799
+ snmpV3PrivKey: row["snmpV3PrivKey"],
1800
+ }).toEqual({
1801
+ snmpVersion: "V3",
1802
+ snmpCommunityString: null,
1803
+ snmpPort: 1161,
1804
+ snmpV3SecurityLevel: "authPriv",
1805
+ snmpV3Username: "netops",
1806
+ snmpV3AuthProtocol: "SHA",
1807
+ snmpV3AuthKey: "auth-secret",
1808
+ snmpV3PrivProtocol: "AES",
1809
+ snmpV3PrivKey: "priv-secret",
1810
+ });
1811
+ });
1812
+
1813
+ /*
1814
+ * The other half of the same guard, and the reason the fix tests for a
1815
+ * DEFINED value rather than simply mirroring everything: a caller that
1816
+ * really did send a flattened column meant it, and must read back what it
1817
+ * sent.
1818
+ */
1819
+ it("still leaves a flattened column the create payload named for itself alone", async () => {
1820
+ const row: Record<string, unknown> = await createdRow({
1821
+ snmpConfigs: [coreRoutersConfig()],
1822
+ snmpCommunityString: "operator-typed",
1823
+ });
1824
+
1825
+ expect(row["snmpCommunityString"]).toBe("operator-typed");
1826
+ // ...while the columns the payload said nothing about are still mirrored.
1827
+ expect(row["snmpVersion"]).toBe("V3");
1828
+ expect(row["snmpV3Username"]).toBe("netops");
1829
+ });
1830
+
1831
+ /*
1832
+ * A scan created with no SNMP settings at all: the derived list and the
1833
+ * flattened columns have to agree, or the two readers (a current probe on
1834
+ * the list, an older one on the columns) would sweep the same subnet
1835
+ * differently.
1836
+ */
1837
+ it("leaves the flattened columns to their own defaults when the list was derived from them", async () => {
1838
+ const row: Record<string, unknown> = await createdRow({});
1839
+
1840
+ expect(row["snmpConfigs"]).toEqual([
1841
+ { id: LEGACY_SNMP_CONFIG_ID, snmpVersion: "V2c" },
1842
+ ]);
1843
+ /*
1844
+ * Untouched — the derive path has nothing to mirror BACK, since the list
1845
+ * it just built came from these columns. They stay undefined so Postgres
1846
+ * applies the same "V2c"/161 defaults the derived config already names.
1847
+ */
1848
+ expect(row["snmpVersion"]).toBeUndefined();
1849
+ expect(row["snmpCommunityString"]).toBeUndefined();
1850
+ });
1851
+ });
1852
+
1853
+ /*
1854
+ * Every shape the column can arrive holding that must be refused, with the
1855
+ * sentence the operator would have seen on the form.
1856
+ *
1857
+ * These are not hypothetical shapes. A jsonb column has no type check of its
1858
+ * own, this hook runs before the model's, and the list is the one field of
1859
+ * this form that is not a plain input — so "the client sent a string" is the
1860
+ * ordinary failure of an API caller reading the docs, not an attack.
1861
+ */
1862
+ const INVALID_LISTS: Array<[string, unknown]> = [
1863
+ ["a bare string where a list belongs", "V2c"],
1864
+ ["an object where a list belongs", { snmpVersion: "V2c" }],
1865
+ ["a number where a list belongs", 161],
1866
+ ["an empty list", []],
1867
+ ["a v3 config with no username", [{ id: "core-routers", snmpVersion: "V3" }]],
1868
+ ];
1869
+
1870
+ /*
1871
+ * The ceiling, built rather than written out: every extra config costs
1872
+ * another SNMP timeout on each address that answers nothing, so a long list
1873
+ * can push a large sweep past the probe's time limit.
1874
+ */
1875
+ const OVERSIZED_LIST: Array<DiscoveryScanSnmpConfig> = [];
1876
+
1877
+ for (let index: number = 0; index < MAX_SNMP_CONFIGS_PER_SCAN + 1; index++) {
1878
+ OVERSIZED_LIST.push({
1879
+ id: `config-${index}`,
1880
+ snmpVersion: "V2c",
1881
+ snmpCommunityString: "public",
1882
+ });
1883
+ }
1884
+
1885
+ INVALID_LISTS.push([
1886
+ `a list of ${MAX_SNMP_CONFIGS_PER_SCAN + 1} configs`,
1887
+ OVERSIZED_LIST,
1888
+ ]);
1889
+
1890
+ describe("NetworkDeviceDiscoveryScanService: validating the credential list", () => {
1891
+ it("rejects an invalid list on create with the shared validator's own message", async () => {
1892
+ for (const [label, value] of INVALID_LISTS) {
1893
+ const error: Error | null = await captureError((): Promise<unknown> => {
1894
+ return onBeforeCreate(makeCreateByWith({ snmpConfigs: value }));
1895
+ });
1896
+
1897
+ expect({
1898
+ posted: label,
1899
+ isBadData: error instanceof BadDataException,
1900
+ message: error?.message,
1901
+ }).toEqual({
1902
+ posted: label,
1903
+ isBadData: true,
1904
+ message: SnmpScanConfigUtil.getValidationError(value),
1905
+ });
1906
+ }
1907
+ });
1908
+
1909
+ it("rejects an invalid list on update with the shared validator's own message", async () => {
1910
+ for (const [label, value] of INVALID_LISTS) {
1911
+ const error: Error | null = await captureError((): Promise<unknown> => {
1912
+ return onBeforeUpdate(makeUpdateBy({ snmpConfigs: value }));
1913
+ });
1914
+
1915
+ expect({
1916
+ posted: label,
1917
+ isBadData: error instanceof BadDataException,
1918
+ message: error?.message,
1919
+ }).toEqual({
1920
+ posted: label,
1921
+ isBadData: true,
1922
+ message: SnmpScanConfigUtil.getValidationError(value),
1923
+ });
1924
+ }
1925
+ });
1926
+
1927
+ /*
1928
+ * A v3 config whose security level asks for authentication with nothing to
1929
+ * authenticate with does not fail loudly: the session is simply rejected by
1930
+ * every device, host after host, and the scan reports zero. Named
1931
+ * separately from the loop above because it is the failure mode this
1932
+ * validation exists for.
1933
+ */
1934
+ it("rejects a v3 config whose security level has no key to go with it", async () => {
1935
+ const error: Error | null = await captureError((): Promise<unknown> => {
1936
+ return onBeforeUpdate(
1937
+ makeUpdateBy({
1938
+ snmpConfigs: [
1939
+ {
1940
+ id: "core-routers",
1941
+ snmpVersion: "V3",
1942
+ snmpV3Username: "netops",
1943
+ snmpV3SecurityLevel: "authPriv",
1944
+ snmpV3AuthKey: "auth-secret",
1945
+ },
1946
+ ],
1947
+ }),
1948
+ );
1949
+ });
1950
+
1951
+ expect(error).toBeInstanceOf(BadDataException);
1952
+ expect(error?.message).toContain("privacy key");
1953
+ });
1954
+
1955
+ /*
1956
+ * The message names WHICH card is wrong. With five credential sets on
1957
+ * screen, "SNMP v3 Username is required" on its own is not something an
1958
+ * operator can act on.
1959
+ */
1960
+ it("names the offending config's position in the list", async () => {
1961
+ const error: Error | null = await captureError((): Promise<unknown> => {
1962
+ return onBeforeUpdate(
1963
+ makeUpdateBy({
1964
+ snmpConfigs: [
1965
+ { id: "access-switches", snmpVersion: "V2c" },
1966
+ { id: "core-routers", snmpVersion: "V3" },
1967
+ ],
1968
+ }),
1969
+ );
1970
+ });
1971
+
1972
+ expect(error?.message).toContain("SNMP config 2");
1973
+ });
1974
+
1975
+ // A valid list is accepted, so the rejections above are not vacuous.
1976
+ it("accepts a well-formed multi-config list", async () => {
1977
+ await expect(
1978
+ onBeforeUpdate(
1979
+ makeUpdateBy({
1980
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
1981
+ }),
1982
+ ),
1983
+ ).resolves.toBeDefined();
1984
+ });
1985
+ });
1986
+
1987
+ describe("NetworkDeviceDiscoveryScanService: normalizing the credential list on write", () => {
1988
+ /*
1989
+ * An id has to be minted rather than left positional. The probe stamps the
1990
+ * answering config's id onto each discovered host and the importer resolves
1991
+ * the host's credentials back out of the list by that id — so an id derived
1992
+ * from a config's POSITION would re-point every host below a deleted card
1993
+ * at a different credential set, and those hosts would then be imported
1994
+ * with a community string that does not work.
1995
+ */
1996
+ it("mints a real id for a config that arrives without one", async () => {
1997
+ const row: Record<string, unknown> = await updatedRow({
1998
+ snmpConfigs: [{ snmpVersion: "V2c", snmpCommunityString: "public" }],
1999
+ });
2000
+
2001
+ const configs: Array<DiscoveryScanSnmpConfig> = row[
2002
+ "snmpConfigs"
2003
+ ] as Array<DiscoveryScanSnmpConfig>;
2004
+
2005
+ expect(configs).toHaveLength(1);
2006
+ expect(ObjectID.isValidUUID(configs[0]!.id as string)).toBe(true);
2007
+ // Specifically NOT the index-derived id the reader falls back to.
2008
+ expect(configs[0]!.id).not.toBe("config-1");
2009
+ });
2010
+
2011
+ /*
2012
+ * And the other half of the same rule: an id that arrived is kept, because
2013
+ * results the probe has already stamped name it.
2014
+ */
2015
+ it("keeps the id a config arrived with", async () => {
2016
+ const row: Record<string, unknown> = await updatedRow({
2017
+ snmpConfigs: [coreRoutersConfig()],
2018
+ });
2019
+
2020
+ expect((row["snmpConfigs"] as Array<DiscoveryScanSnmpConfig>)[0]!.id).toBe(
2021
+ "core-routers",
2022
+ );
2023
+ });
2024
+
2025
+ /*
2026
+ * Everything a form can post loosely, tightened once at the write rather
2027
+ * than at each of the four readers (probe, importer, form, service). The
2028
+ * version in particular is normalized to the stored spelling, so a
2029
+ * hand-written "3" cannot make the same credentials read as a CHANGE the
2030
+ * next time the form posts "V3" back.
2031
+ */
2032
+ it("trims every string, coerces the port and normalizes the version", async () => {
2033
+ const row: Record<string, unknown> = await updatedRow({
2034
+ snmpConfigs: [
2035
+ {
2036
+ id: " core-routers ",
2037
+ name: " Core routers ",
2038
+ snmpVersion: "3",
2039
+ snmpCommunityString: " private ",
2040
+ snmpPort: "1161",
2041
+ snmpV3SecurityLevel: " authPriv ",
2042
+ snmpV3Username: " netops ",
2043
+ snmpV3AuthProtocol: " SHA ",
2044
+ snmpV3AuthKey: " auth-secret ",
2045
+ snmpV3PrivProtocol: " AES ",
2046
+ snmpV3PrivKey: " priv-secret ",
2047
+ },
2048
+ ],
2049
+ });
2050
+
2051
+ expect(row["snmpConfigs"]).toEqual([
2052
+ {
2053
+ id: "core-routers",
2054
+ name: "Core routers",
2055
+ snmpVersion: "V3",
2056
+ snmpCommunityString: "private",
2057
+ snmpPort: 1161,
2058
+ snmpV3SecurityLevel: "authPriv",
2059
+ snmpV3Username: "netops",
2060
+ snmpV3AuthProtocol: "SHA",
2061
+ snmpV3AuthKey: "auth-secret",
2062
+ snmpV3PrivProtocol: "AES",
2063
+ snmpV3PrivKey: "priv-secret",
2064
+ },
2065
+ ]);
2066
+ });
2067
+ });
2068
+
2069
+ /*
2070
+ * THE MIRROR.
2071
+ *
2072
+ * A probe is deployed separately from the server and is routinely a version
2073
+ * behind. A probe that has never heard of `snmpConfigs` reads the flattened
2074
+ * columns and NOTHING ELSE — so without this, saving a multi-config scan
2075
+ * would leave those columns holding whatever the scan was created with, or
2076
+ * the bare column defaults (v2c / "public" / 161), and every older probe in
2077
+ * the fleet would sweep with credentials nobody chose and report a confident
2078
+ * zero. The operator would see a scan that finds nothing and no indication
2079
+ * anywhere that the credentials it advertises are not the ones it is using.
2080
+ *
2081
+ * The FIRST config specifically, because that is the one such a probe would
2082
+ * have been handed under the old single-config UI, and because the list is
2083
+ * ordered by the operator's own preference.
2084
+ */
2085
+ describe("NetworkDeviceDiscoveryScanService: mirroring the list onto the flattened columns", () => {
2086
+ it("writes the nine flattened columns from the list's first config", async () => {
2087
+ const row: Record<string, unknown> = await updatedRow({
2088
+ snmpConfigs: [coreRoutersConfig(), ...storedSnmpConfigs()],
2089
+ });
2090
+
2091
+ /*
2092
+ * All nine asserted together, and the second config's own community
2093
+ * string ("public") must NOT appear among them — that is the assertion
2094
+ * that the FIRST entry is the one mirrored, rather than the last one
2095
+ * written or a merge of the two.
2096
+ */
2097
+ expect({
2098
+ snmpVersion: row["snmpVersion"],
2099
+ snmpCommunityString: row["snmpCommunityString"],
2100
+ snmpPort: row["snmpPort"],
2101
+ snmpV3SecurityLevel: row["snmpV3SecurityLevel"],
2102
+ snmpV3Username: row["snmpV3Username"],
2103
+ snmpV3AuthProtocol: row["snmpV3AuthProtocol"],
2104
+ snmpV3AuthKey: row["snmpV3AuthKey"],
2105
+ snmpV3PrivProtocol: row["snmpV3PrivProtocol"],
2106
+ snmpV3PrivKey: row["snmpV3PrivKey"],
2107
+ }).toEqual({
2108
+ snmpVersion: "V3",
2109
+ snmpCommunityString: null,
2110
+ snmpPort: 1161,
2111
+ snmpV3SecurityLevel: "authPriv",
2112
+ snmpV3Username: "netops",
2113
+ snmpV3AuthProtocol: "SHA",
2114
+ snmpV3AuthKey: "auth-secret",
2115
+ snmpV3PrivProtocol: "AES",
2116
+ snmpV3PrivKey: "priv-secret",
2117
+ });
2118
+ });
2119
+
2120
+ /*
2121
+ * A caller that sends BOTH the list and a flattened column meant the
2122
+ * flattened one. Overwriting it would make the payload and the row disagree
2123
+ * in a way only the database could reveal — the caller would be told the
2124
+ * write succeeded and would then read back a value it never sent.
2125
+ */
2126
+ it("leaves a flattened column the payload named for itself alone", async () => {
2127
+ const row: Record<string, unknown> = await updatedRow({
2128
+ snmpConfigs: [coreRoutersConfig()],
2129
+ snmpCommunityString: "operator-typed",
2130
+ });
2131
+
2132
+ expect(row["snmpCommunityString"]).toBe("operator-typed");
2133
+ // ...while the columns the payload said nothing about are still mirrored.
2134
+ expect(row["snmpVersion"]).toBe("V3");
2135
+ expect(row["snmpV3Username"]).toBe("netops");
2136
+ });
2137
+
2138
+ /*
2139
+ * Clearing the list puts the scan back on its flattened columns, exactly
2140
+ * where a scan created before this feature already is. Mirroring anything
2141
+ * here would erase the very credentials the fallback just landed on: there
2142
+ * is no first config to mirror FROM, so the mirror would write the column
2143
+ * defaults over whatever the row actually holds.
2144
+ */
2145
+ it("clears the column and mirrors nothing when the list is cleared", async () => {
2146
+ for (const clearedValue of [null, ""]) {
2147
+ const row: Record<string, unknown> = await updatedRow({
2148
+ snmpConfigs: clearedValue,
2149
+ });
2150
+
2151
+ // The whole payload, so "mirrors nothing" is a real assertion.
2152
+ expect(row).toEqual({ snmpConfigs: null });
2153
+ }
2154
+ });
2155
+ });
2156
+
2157
+ /*
2158
+ * An update written through the FLATTENED columns alone, on a scan that has a
2159
+ * credential list.
2160
+ *
2161
+ * The list shadows those columns — the reader consults it and ignores them —
2162
+ * so such a write would land in the database and change nothing about the
2163
+ * sweep. That is the worst of both outcomes: the caller is told it succeeded
2164
+ * and the scan keeps the credentials it had. Which of the two honest answers
2165
+ * applies depends only on how many configs the row holds: a single-credential
2166
+ * scan has its list rebuilt from the columns the save just wrote, and a
2167
+ * multi-credential one is refused, because no set of flattened columns could
2168
+ * describe it and overwriting one config would silently discard the others.
2169
+ */
2170
+ describe("NetworkDeviceDiscoveryScanService: an update through the flattened columns alone", () => {
2171
+ /*
2172
+ * The rebuild happens AFTER the write, not in the payload, and it has to:
2173
+ * one update payload is shared by every row the query matched, and the list
2174
+ * to rebuild is a function of each ROW. So this is asserted through a whole
2175
+ * save rather than through the hook alone — the payload the caller sent is
2176
+ * carried through untouched, and the list is put back in step by the
2177
+ * reconciling write that follows.
2178
+ */
2179
+ it("rebuilds the list from the columns the save wrote, so the two describe the same credentials", async () => {
2180
+ storedScans = [storedScan()];
2181
+
2182
+ const data: Record<string, unknown> = { snmpCommunityString: "private" };
2183
+
2184
+ const writes: Array<ReconcileWrite> = await saveSettings(data);
2185
+
2186
+ // The caller's own payload is not rewritten on the way through.
2187
+ expect(data["snmpCommunityString"]).toBe("private");
2188
+ expect(Object.prototype.hasOwnProperty.call(data, "snmpConfigs")).toBe(
2189
+ false,
2190
+ );
2191
+
2192
+ /*
2193
+ * The rebuilt list carries the community string the save just wrote, NOT
2194
+ * the one the row's list held a moment ago. That is the whole point: a
2195
+ * list still holding "public" would go on shadowing the "private" the
2196
+ * caller was told had been stored, and the probe would keep sweeping with
2197
+ * the old credential.
2198
+ *
2199
+ * The id is the synthetic legacy one, and the name the stored list carried
2200
+ * is gone — a save through these columns has no way to express a name, and
2201
+ * inventing one would be the rebuild claiming to know something the caller
2202
+ * never sent.
2203
+ */
2204
+ expect(writes).toHaveLength(1);
2205
+ expect(writes[0]!.data["snmpConfigs"]).toEqual([
2206
+ {
2207
+ id: LEGACY_SNMP_CONFIG_ID,
2208
+ snmpVersion: "V2c",
2209
+ snmpCommunityString: "private",
2210
+ snmpPort: 161,
2211
+ },
2212
+ ]);
2213
+ });
2214
+
2215
+ /*
2216
+ * WHERE THE TWO CHANGES MEET AGAIN (issues #3445 + #3458): the rebuild is
2217
+ * skipped outright for a scan that sends no SNMP.
2218
+ *
2219
+ * An ICMP-only scan stores nulls in every SNMP column, so resolving a list
2220
+ * out of them would materialize one credential set that says nothing at all
2221
+ * — no version, no community string, no port — on a scan that will never
2222
+ * send a single SNMP packet. The Edit dialog seeds its list editor from this
2223
+ * column, so that empty card would then be shown to an operator who had
2224
+ * asked for a ping sweep, on a step the form does not even display.
2225
+ */
2226
+ it("does not materialize a list for an ICMP-only scan saved through those columns", async () => {
2227
+ storedScans = [
2228
+ storedScan({
2229
+ isSnmpEnabled: false,
2230
+ snmpConfigs: null,
2231
+ snmpVersion: undefined,
2232
+ snmpCommunityString: null,
2233
+ snmpPort: undefined,
2234
+ }),
2235
+ ];
2236
+
2237
+ /*
2238
+ * An empty box, which is what the hidden SNMP step posts on a scan whose
2239
+ * credentials were cleared when the method was turned off. It matches the
2240
+ * null the row already holds, so nothing about the sweep changed either.
2241
+ */
2242
+ const writes: Array<ReconcileWrite> = await saveSettings({
2243
+ snmpCommunityString: "",
2244
+ });
2245
+
2246
+ expect(writes).toEqual([]);
2247
+ });
2248
+
2249
+ it("refuses the same update on a multi-config scan, naming the field to use instead", async () => {
2250
+ storedScans = [
2251
+ storedScan({
2252
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
2253
+ }),
2254
+ ];
2255
+
2256
+ const error: Error | null = await captureError((): Promise<unknown> => {
2257
+ return onBeforeUpdate(makeUpdateBy({ snmpCommunityString: "private" }));
2258
+ });
2259
+
2260
+ /*
2261
+ * There is no set of flattened columns that could express a two-config
2262
+ * scan, and picking one config to overwrite would silently discard the
2263
+ * other — so the only honest answer is a refusal that says what to send.
2264
+ */
2265
+ expect(error).toBeInstanceOf(BadDataException);
2266
+ expect(error?.message).toContain("snmpConfigs");
2267
+ expect(error?.message).toContain("2");
2268
+ });
2269
+
2270
+ it("leaves an update that sends the list itself alone, even on a multi-config scan", async () => {
2271
+ storedScans = [
2272
+ storedScan({
2273
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
2274
+ }),
2275
+ ];
2276
+
2277
+ const row: Record<string, unknown> = await updatedRow({
2278
+ snmpConfigs: [coreRoutersConfig()],
2279
+ snmpCommunityString: "operator-typed",
2280
+ });
2281
+
2282
+ expect(row["snmpConfigs"]).toHaveLength(1);
2283
+ expect(row["snmpCommunityString"]).toBe("operator-typed");
2284
+ });
2285
+
2286
+ /*
2287
+ * The reconciliation must not reach for a payload that never mentions SNMP
2288
+ * at all. Adding `snmpConfigs: null` to a rename or a target change would
2289
+ * throw away the credential list of every scan that got renamed.
2290
+ */
2291
+ it("leaves an update that touches no SNMP column at all completely alone", async () => {
2292
+ const row: Record<string, unknown> = await updatedRow({
2293
+ cidr: "10.0.0.0/24",
2294
+ });
2295
+
2296
+ expect(Object.prototype.hasOwnProperty.call(row, "snmpConfigs")).toBe(
2297
+ false,
2298
+ );
2299
+ });
2300
+ });
2301
+
2302
+ /*
2303
+ * The scan's METHOD (issue #3445).
2304
+ *
2305
+ * A scan that will not send SNMP must not be STORED carrying SNMP settings.
2306
+ * Hiding the fields in the wizard is not enough on its own, for two separate
2307
+ * reasons — ModelForm posts every declared field whether or not it was visible,
2308
+ * and this hook is also the only thing standing between a direct API call and
2309
+ * the table — so the clearing lives here, on the write path every writer takes.
2310
+ *
2311
+ * The nulls below are the part that is easy to "simplify" into a bug. TypeORM
2312
+ * OMITS an undefined property from the INSERT, at which point Postgres applies
2313
+ * this table's column defaults ('V2c' for snmpVersion, 161 for snmpPort), and
2314
+ * the stored row ends up claiming to be a v2c scan on port 161 while its own
2315
+ * method column says it sends no SNMP at all. That was verified empirically
2316
+ * against Postgres, not reasoned about: an explicit null is the only value that
2317
+ * beats a column default.
2318
+ */
2319
+ describe("NetworkDeviceDiscoveryScanService SNMP config on create", () => {
2320
+ const SNMP_CONFIG_COLUMNS: Array<string> = [
2321
+ "snmpVersion",
2322
+ "snmpCommunityString",
2323
+ "snmpPort",
2324
+ "snmpV3SecurityLevel",
2325
+ "snmpV3Username",
2326
+ "snmpV3AuthProtocol",
2327
+ "snmpV3AuthKey",
2328
+ "snmpV3PrivProtocol",
2329
+ "snmpV3PrivKey",
2330
+ ];
2331
+
2332
+ /*
2333
+ * A full v3 credential set, which is the worst case worth pinning: it is the
2334
+ * one that carries a passphrase, and the one an operator is most likely to
2335
+ * have typed before deciding they only wanted a ping sweep after all.
2336
+ */
2337
+ const SNMP_CREDENTIALS: Record<string, unknown> = {
2338
+ snmpVersion: "V3",
2339
+ snmpCommunityString: "public",
2340
+ snmpPort: 1161,
2341
+ snmpV3SecurityLevel: "authPriv",
2342
+ snmpV3Username: "netops",
2343
+ snmpV3AuthProtocol: "sha",
2344
+ snmpV3AuthKey: "auth-passphrase",
2345
+ snmpV3PrivProtocol: "aes",
2346
+ snmpV3PrivKey: "priv-passphrase",
2347
+ };
2348
+
2349
+ /*
2350
+ * `isSnmpEnabled` is typed unknown for the same reason `cidr` is above: this
2351
+ * hook runs before the model's own type checks, so an API client really can
2352
+ * send a string or a number here. Passing NO_METHOD_KEY deletes the property
2353
+ * outright, which is how a request body from a client that has never heard of
2354
+ * the column reaches the hook.
2355
+ */
2356
+ const NO_METHOD_KEY: symbol = Symbol("no isSnmpEnabled key");
2357
+
2358
+ type MakeScanFunction = (
2359
+ isSnmpEnabled: unknown,
2360
+ credentials?: Record<string, unknown>,
2361
+ ) => CreateBy<NetworkDeviceDiscoveryScan>;
2362
+
2363
+ const makeScan: MakeScanFunction = (
2364
+ isSnmpEnabled: unknown,
2365
+ credentials: Record<string, unknown> = {},
2366
+ ): CreateBy<NetworkDeviceDiscoveryScan> => {
2367
+ const createBy: CreateBy<NetworkDeviceDiscoveryScan> =
2368
+ makeCreateBy("192.168.1.0/24");
2369
+
2370
+ const data: Record<string, unknown> = createBy.data as unknown as Record<
2371
+ string,
2372
+ unknown
2373
+ >;
2374
+
2375
+ if (isSnmpEnabled === NO_METHOD_KEY) {
2376
+ delete data["isSnmpEnabled"];
2377
+ } else {
2378
+ data["isSnmpEnabled"] = isSnmpEnabled;
2379
+ }
2380
+
2381
+ for (const key of Object.keys(credentials)) {
2382
+ data[key] = credentials[key];
2383
+ }
2384
+
2385
+ return createBy;
2386
+ };
2387
+
2388
+ type CreatedScanFunction = (
2389
+ isSnmpEnabled: unknown,
2390
+ credentials?: Record<string, unknown>,
2391
+ ) => Promise<Record<string, unknown>>;
2392
+
2393
+ // What the hook would actually hand to the INSERT, for a given request body.
2394
+ const createdScan: CreatedScanFunction = async (
2395
+ isSnmpEnabled: unknown,
2396
+ credentials: Record<string, unknown> = {},
2397
+ ): Promise<Record<string, unknown>> => {
2398
+ const createBy: CreateBy<NetworkDeviceDiscoveryScan> = makeScan(
2399
+ isSnmpEnabled,
2400
+ credentials,
2401
+ );
2402
+
2403
+ await onBeforeCreate(createBy);
2404
+
2405
+ return createBy.data as unknown as Record<string, unknown>;
2406
+ };
2407
+
2408
+ /*
2409
+ * `null`, and specifically never `undefined`. The two look like the same
2410
+ * assertion and are completely different values to Postgres: TypeORM omits
2411
+ * an undefined property from the INSERT, the column default fills it back in
2412
+ * ('V2c', 161), and the stored row claims to be a v2c scan on port 161 while
2413
+ * its own method column says it sends no SNMP at all.
2414
+ *
2415
+ * The object-wrapped expect is what makes toEqual tell them apart — a bare
2416
+ * `toEqual(null)` on a received `undefined` would pass, because toEqual
2417
+ * treats an undefined property as an absent one — and the hasOwnProperty
2418
+ * check is what pins that the columns are WRITTEN rather than deleted.
2419
+ */
2420
+ it("nulls every SNMP column of a scan that sends no SNMP, with null and never undefined", async () => {
2421
+ const stored: Record<string, unknown> = await createdScan(
2422
+ false,
2423
+ SNMP_CREDENTIALS,
2424
+ );
2425
+
2426
+ for (const column of SNMP_CONFIG_COLUMNS) {
2427
+ expect({ column: column, value: stored[column] }).toEqual({
2428
+ column: column,
2429
+ value: null,
2430
+ });
2431
+ expect(Object.prototype.hasOwnProperty.call(stored, column)).toBe(true);
2432
+ }
2433
+
2434
+ // The two columns that actually carry a DEFAULT in this table.
2435
+ expect(stored["snmpVersion"]).not.toBe("V2c");
2436
+ expect(stored["snmpPort"]).not.toBe(161);
2437
+ });
2438
+
2439
+ it("keeps the credentials of a scan that does send SNMP", async () => {
2440
+ const stored: Record<string, unknown> = await createdScan(
2441
+ true,
2442
+ SNMP_CREDENTIALS,
2443
+ );
2444
+
2445
+ for (const column of SNMP_CONFIG_COLUMNS) {
2446
+ expect(stored[column]).toBe(SNMP_CREDENTIALS[column]);
2447
+ }
2448
+ });
2449
+
2450
+ /*
2451
+ * The legacy and API case, and the one that fails silently.
2452
+ *
2453
+ * A request body written before this column existed carries no method at all.
2454
+ * Reading that absence as "SNMP is off" would clear the credentials of every
2455
+ * such scan and turn the entire product's discovery into a ping sweep, with
2456
+ * no error raised anywhere — the only symptom being addresses found where
2457
+ * devices used to be. Absence means SNMP, exactly as it did before the column.
2458
+ */
2459
+ it("keeps the credentials of a scan that never mentions the method at all", async () => {
2460
+ const createBy: CreateBy<NetworkDeviceDiscoveryScan> = makeScan(
2461
+ NO_METHOD_KEY,
2462
+ SNMP_CREDENTIALS,
2463
+ );
2464
+
2465
+ expect(
2466
+ Object.prototype.hasOwnProperty.call(createBy.data, "isSnmpEnabled"),
2467
+ ).toBe(false);
2468
+
2469
+ await onBeforeCreate(createBy);
2470
+
2471
+ const stored: Record<string, unknown> = createBy.data as unknown as Record<
2472
+ string,
2473
+ unknown
2474
+ >;
2475
+
2476
+ for (const column of SNMP_CONFIG_COLUMNS) {
2477
+ expect(stored[column]).toBe(SNMP_CREDENTIALS[column]);
2478
+ }
2479
+ });
2480
+
2481
+ /*
2482
+ * ...and neither does an explicit null, which is what a partially-selected
2483
+ * row or a JSON body that spells "unset" as null produces. Only a real
2484
+ * boolean false turns SNMP off, so a value this hook did not recognise fails
2485
+ * safe toward the scan the product has always run.
2486
+ */
2487
+ it.each([
2488
+ ["an undefined method", undefined],
2489
+ ["a null method", null],
2490
+ ['the string "false"', "false"],
2491
+ ["a zero", 0],
2492
+ ])("keeps the credentials for %s", async (_label: string, value: unknown) => {
2493
+ const stored: Record<string, unknown> = await createdScan(
2494
+ value,
2495
+ SNMP_CREDENTIALS,
2496
+ );
2497
+
2498
+ expect(stored["snmpVersion"]).toBe("V3");
2499
+ expect(stored["snmpV3PrivKey"]).toBe("priv-passphrase");
2500
+ });
2501
+
2502
+ /*
2503
+ * The columns are written, not deleted, so a request that carried no
2504
+ * credentials in the first place still ends up with explicit nulls — which is
2505
+ * the whole reason the clearing exists, since it is the empty request that
2506
+ * would otherwise be filled in by the column defaults.
2507
+ */
2508
+ it("nulls the columns even when the request carried no credentials at all", async () => {
2509
+ const stored: Record<string, unknown> = await createdScan(false);
2510
+
2511
+ for (const column of SNMP_CONFIG_COLUMNS) {
2512
+ expect(stored[column]).toBeNull();
2513
+ }
2514
+ });
2515
+
2516
+ /*
2517
+ * The one column that must NOT be nulled by the clearing, and the mistake is
2518
+ * a single line: adding "isSnmpEnabled" to the list of SNMP columns. A null
2519
+ * method reads as SNMP-enabled everywhere in the product, so the row would
2520
+ * come back out claiming to be an SNMP scan whose credentials are all null —
2521
+ * an unrunnable sweep that also contradicts what the operator asked for.
2522
+ */
2523
+ it("leaves the method column itself alone", async () => {
2524
+ const stored: Record<string, unknown> = await createdScan(
2525
+ false,
2526
+ SNMP_CREDENTIALS,
2527
+ );
2528
+
2529
+ expect(stored["isSnmpEnabled"]).toBe(false);
2530
+ expect(stored["isSnmpEnabled"]).not.toBeNull();
2531
+ });
2532
+
2533
+ it("does not disturb anything else about the scan", async () => {
2534
+ const createBy: CreateBy<NetworkDeviceDiscoveryScan> = makeScan(false, {
2535
+ ...SNMP_CREDENTIALS,
2536
+ name: " Ping Sweep - Region 1100 ",
2537
+ isRecurring: true,
2538
+ rescanIntervalInMinutes: 60,
2539
+ });
2540
+
2541
+ await onBeforeCreate(createBy);
2542
+
2543
+ const stored: Record<string, unknown> = createBy.data as unknown as Record<
2544
+ string,
2545
+ unknown
2546
+ >;
2547
+
2548
+ expect(stored["cidr"]).toBe("192.168.1.0/24");
2549
+ expect(stored["probeId"]).toBe(PROBE_ID);
2550
+ expect(stored["isRecurring"]).toBe(true);
2551
+ expect(stored["rescanIntervalInMinutes"]).toBe(60);
2552
+ // Still normalized by the name rules, which run on the same hook.
2553
+ expect(stored["name"]).toBe("Ping Sweep - Region 1100");
2554
+ });
2555
+
2556
+ it("returns the same createBy, so the cleared values are what gets written", async () => {
2557
+ const createBy: CreateBy<NetworkDeviceDiscoveryScan> = makeScan(
2558
+ false,
2559
+ SNMP_CREDENTIALS,
2560
+ );
2561
+
2562
+ const result: unknown = await onBeforeCreate(createBy);
2563
+
2564
+ const returned: CreateBy<NetworkDeviceDiscoveryScan> = (
2565
+ result as { createBy: CreateBy<NetworkDeviceDiscoveryScan> }
2566
+ ).createBy;
2567
+
2568
+ expect(returned).toBe(createBy);
2569
+ expect(
2570
+ (returned.data as unknown as Record<string, unknown>)["snmpV3PrivKey"],
2571
+ ).toBeNull();
2572
+ });
2573
+
2574
+ /*
2575
+ * Turning SNMP off does not turn the rest of the hook off. Every validation
2576
+ * that guarded a scan before this column existed still guards an ICMP-only
2577
+ * one — the sweep is the same sweep, and a target the probe would refuse is
2578
+ * still a target that must not be stored.
2579
+ */
2580
+ it("still rejects a bad target on an ICMP-only scan", async () => {
2581
+ const reversedRange: CreateBy<NetworkDeviceDiscoveryScan> = makeScan(false);
2582
+ (reversedRange.data as unknown as Record<string, unknown>)["cidr"] =
2583
+ "10.22-16.0.1";
2584
+
2585
+ await expect(onBeforeCreate(reversedRange)).rejects.toThrow(/reversed/);
2586
+
2587
+ const noTarget: CreateBy<NetworkDeviceDiscoveryScan> = makeScan(false);
2588
+ delete (noTarget.data as unknown as Record<string, unknown>)["cidr"];
2589
+
2590
+ await expect(onBeforeCreate(noTarget)).rejects.toThrow(BadDataException);
2591
+ });
2592
+
2593
+ it("still rejects an oversized target on an ICMP-only scan", async () => {
2594
+ const oversized: CreateBy<NetworkDeviceDiscoveryScan> = makeScan(false);
2595
+ (oversized.data as unknown as Record<string, unknown>)["cidr"] =
2596
+ "10.0.0.0/8";
2597
+
2598
+ await expect(onBeforeCreate(oversized)).rejects.toThrow(/exceeding the/);
2599
+ });
2600
+
2601
+ it("still rejects a bad name on an ICMP-only scan", async () => {
2602
+ const badName: CreateBy<NetworkDeviceDiscoveryScan> = makeScan(false, {
2603
+ name: "a".repeat(101),
2604
+ });
2605
+
2606
+ await expect(onBeforeCreate(badName)).rejects.toThrow(
2607
+ ScanNameUtil.getValidationError("a".repeat(101)) as string,
2608
+ );
2609
+ });
2610
+ });
2611
+
2612
+ /*
2613
+ * Every column a scan that will not send SNMP must not carry, in the order the
2614
+ * service nulls them.
2615
+ *
2616
+ * The LIST leads, and that is the merge of the two changes in one line: it is
2617
+ * where a scan's community strings and v3 passphrases actually live now (issue
2618
+ * #3458), so clearing the nine flattened columns while leaving it behind would
2619
+ * store an ICMP-only scan whose own method column says it sends no SNMP,
2620
+ * carrying every credential the operator had typed. The nine below it are what
2621
+ * a probe that predates the list still reads, and are cleared for the reason
2622
+ * issue #3445 gave: an undefined property is omitted from the INSERT and
2623
+ * Postgres fills in this table's own defaults ('V2c', 161).
2624
+ */
2625
+ const EVERY_SNMP_COLUMN: Array<string> = [
2626
+ "snmpConfigs",
2627
+ "snmpVersion",
2628
+ "snmpCommunityString",
2629
+ "snmpPort",
2630
+ "snmpV3SecurityLevel",
2631
+ "snmpV3Username",
2632
+ "snmpV3AuthProtocol",
2633
+ "snmpV3AuthKey",
2634
+ "snmpV3PrivProtocol",
2635
+ "snmpV3PrivKey",
2636
+ ];
2637
+
2638
+ /*
2639
+ * ---------------------------------------------------------------------------
2640
+ * WHERE THE TWO CHANGES MEET, ON CREATE (issues #3445 + #3458 together).
2641
+ * ---------------------------------------------------------------------------
2642
+ *
2643
+ * Each half is settled on its own above. Every scan the product creates leaves
2644
+ * the create hook WITH a credential list, and a list it cannot store is refused
2645
+ * as a 400 (#3458). A scan that will not send SNMP leaves the same hook
2646
+ * carrying no SNMP settings at all (#3445). Put together on one request they
2647
+ * contradict each other unless the order is right, because the wizard posts
2648
+ * both fields at once:
2649
+ *
2650
+ * - The SNMP step is hidden for an ICMP-only scan, but ModelForm builds the
2651
+ * request body out of every DECLARED field without checking whether it was
2652
+ * visible. So the half-finished credential card an operator typed before
2653
+ * they unticked "Check SNMP on hosts that answer" is still in the POST.
2654
+ * Validating it would refuse the create with a sentence about a step that
2655
+ * is no longer on screen — which is issue #3445's own original symptom
2656
+ * ("SNMP Version is required" blocking a ping sweep), reintroduced through
2657
+ * the new field.
2658
+ * - And the derive step would hand an ICMP-only scan a credential list built
2659
+ * out of the flattened columns, which the clearing then nulls a few lines
2660
+ * later. Deriving a list only to throw it away is at best a confusing way
2661
+ * to write null; get the order wrong and the row KEEPS it, which is a scan
2662
+ * whose method column says it sends no SNMP while holding a v3 passphrase.
2663
+ *
2664
+ * So both passes are gated on ScanModeUtil.isSnmpEnabled(createBy.data), and
2665
+ * the clearing runs last over a set that has `snmpConfigs` in it.
2666
+ */
2667
+ describe("NetworkDeviceDiscoveryScanService: an ICMP-only scan never gets a credential list", () => {
2668
+ /*
2669
+ * The counterpart of "gives even a scan created with no SNMP settings at all
2670
+ * a list" above: the same bare payload, with the method turned off, must NOT
2671
+ * come out of the hook holding the derived one-entry list — so the invariant
2672
+ * the Edit dialog leans on is deliberately not extended to a scan whose SNMP
2673
+ * step that dialog does not show either.
2674
+ *
2675
+ * Null, and specifically not undefined. Left undefined this column is simply
2676
+ * omitted from the INSERT, which happens to be the same value here — but the
2677
+ * clearing exists precisely because "whatever the database decides" is not an
2678
+ * answer this hook is allowed to give about an SNMP column, and the two
2679
+ * spellings are told apart by wrapping the value in an object (a bare
2680
+ * `toEqual(null)` on a received undefined would pass).
2681
+ */
2682
+ it("stores a null credential list rather than deriving one from the flattened columns", async () => {
2683
+ const row: Record<string, unknown> = await createdRow({
2684
+ isSnmpEnabled: false,
2685
+ });
2686
+
2687
+ expect({ snmpConfigs: row["snmpConfigs"] }).toEqual({ snmpConfigs: null });
2688
+ });
2689
+
2690
+ /*
2691
+ * Every list shape the SNMP path refuses — a bare string, an object, a
2692
+ * number, an empty list, a v3 config with no username, a list past the
2693
+ * ceiling — accepted here, and stored as nothing.
2694
+ *
2695
+ * Both halves of that in one loop, because either one alone would pass for
2696
+ * the wrong reason: a hook that validated the list and then cleared it would
2697
+ * still store null, and a hook that skipped validation but kept the list
2698
+ * would still not throw.
2699
+ */
2700
+ it("neither refuses the create nor stores the list when the hidden SNMP step posts a bad one", async () => {
2701
+ for (const [label, value] of INVALID_LISTS) {
2702
+ const createBy: CreateBy<NetworkDeviceDiscoveryScan> = makeCreateByWith({
2703
+ isSnmpEnabled: false,
2704
+ snmpConfigs: value,
2705
+ });
2706
+
2707
+ const error: Error | null = await captureError((): Promise<unknown> => {
2708
+ return onBeforeCreate(createBy);
2709
+ });
2710
+
2711
+ const row: Record<string, unknown> = createBy.data as unknown as Record<
2712
+ string,
2713
+ unknown
2714
+ >;
2715
+
2716
+ expect({
2717
+ posted: label,
2718
+ error: error === null ? null : error.message,
2719
+ snmpConfigs: row["snmpConfigs"],
2720
+ }).toEqual({
2721
+ posted: label,
2722
+ error: null,
2723
+ snmpConfigs: null,
2724
+ });
2725
+ }
2726
+ });
2727
+
2728
+ /*
2729
+ * The whole set at once, from a payload that carries a perfectly VALID
2730
+ * two-config list as well as a full set of v3 credentials — which is what an
2731
+ * operator who worked through the SNMP step and then changed their mind
2732
+ * actually posts. Nothing survives except the method itself.
2733
+ */
2734
+ it("clears the credential list along with the nine flattened columns", async () => {
2735
+ const row: Record<string, unknown> = await createdRow({
2736
+ isSnmpEnabled: false,
2737
+ snmpConfigs: [coreRoutersConfig(), ...storedSnmpConfigs()],
2738
+ snmpVersion: "V3",
2739
+ snmpCommunityString: "public",
2740
+ snmpPort: 1161,
2741
+ snmpV3SecurityLevel: "authPriv",
2742
+ snmpV3Username: "netops",
2743
+ snmpV3AuthProtocol: "SHA",
2744
+ snmpV3AuthKey: "auth-secret",
2745
+ snmpV3PrivProtocol: "AES",
2746
+ snmpV3PrivKey: "priv-secret",
2747
+ });
2748
+
2749
+ for (const column of EVERY_SNMP_COLUMN) {
2750
+ expect({ column: column, value: row[column] }).toEqual({
2751
+ column: column,
2752
+ value: null,
2753
+ });
2754
+ }
2755
+
2756
+ /*
2757
+ * The one column the clearing must not reach, restated here because this
2758
+ * is the payload where getting it wrong is invisible: a nulled method
2759
+ * reads as SNMP-ENABLED everywhere in the product, so the row would come
2760
+ * back out as an SNMP scan whose every credential is null — an unrunnable
2761
+ * sweep that also contradicts what the operator asked for.
2762
+ */
2763
+ expect(row["isSnmpEnabled"]).toBe(false);
2764
+ });
2765
+
2766
+ /*
2767
+ * And the mirror is off on this path too. `applySnmpConfigs` is what writes
2768
+ * the flattened columns from the list's first config, so a version of the
2769
+ * hook that cleared FIRST and mirrored afterwards would leave an ICMP-only
2770
+ * row advertising v3/1161/netops — credentials no probe will ever send, on a
2771
+ * scan that says it sends none.
2772
+ */
2773
+ it("does not mirror the first config onto the flattened columns on the way past", async () => {
2774
+ const row: Record<string, unknown> = await createdRow({
2775
+ isSnmpEnabled: false,
2776
+ snmpConfigs: [coreRoutersConfig()],
2777
+ });
2778
+
2779
+ expect(row["snmpV3Username"]).toBeNull();
2780
+ expect(row["snmpPort"]).toBeNull();
2781
+ });
2782
+
2783
+ /*
2784
+ * The other direction, so none of the above is vacuous: the very same
2785
+ * payload with the method left ON keeps its list and mirrors it. If the gate
2786
+ * were stuck closed, every test in this describe would still pass while the
2787
+ * feature did nothing.
2788
+ */
2789
+ it("still keeps and mirrors the list when the same payload leaves SNMP on", async () => {
2790
+ const row: Record<string, unknown> = await createdRow({
2791
+ isSnmpEnabled: true,
2792
+ snmpConfigs: [coreRoutersConfig()],
2793
+ });
2794
+
2795
+ expect(row["snmpConfigs"]).toHaveLength(1);
2796
+ expect(row["snmpV3Username"]).toBe("netops");
2797
+ });
2798
+ });
2799
+
2800
+ describe("NetworkDeviceDiscoveryScanService: the credential list retires a run like any other sweep column", () => {
2801
+ /*
2802
+ * The results on the row were found with the OLD credentials. Once the list
2803
+ * changes they describe a sweep that no longer exists — and, worse than
2804
+ * merely being stale, the Review Results dialog would offer them for import
2805
+ * under the new credentials and the auto-import worker would create devices
2806
+ * from them.
2807
+ */
2808
+ it("retires the run and clears the results when a config is added to the list", async () => {
2809
+ const writes: Array<ReconcileWrite> = await saveSettings({
2810
+ ...unchangedSave(),
2811
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
2812
+ });
2813
+
2814
+ expect(writes).toHaveLength(1);
2815
+ expect(writes[0]!.data).toEqual({
2816
+ status: "Pending",
2817
+ statusMessage: expect.stringContaining("queued to run again"),
2818
+ startedAt: null,
2819
+ completedAt: null,
2820
+ nextScanAt: null,
2821
+ discoveredDevices: null,
2822
+ scannedHostCount: null,
2823
+ respondedHostCount: null,
2824
+ autoImportProcessedAt: null,
2825
+ });
2826
+ });
2827
+
2828
+ /*
2829
+ * Reordering alone is a real change, not a cosmetic one: the order is the
2830
+ * order the probe tries the configs in per host, and the first entry is
2831
+ * what an older probe sweeps with. A scan whose preferred credential moved
2832
+ * can find a different set of hosts, so its previous results are no more
2833
+ * trustworthy than they would be after an edit.
2834
+ */
2835
+ it("retires the run when the list is only reordered", async () => {
2836
+ storedScans = [
2837
+ storedScan({
2838
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
2839
+ }),
2840
+ ];
2841
+
2842
+ const writes: Array<ReconcileWrite> = await saveSettings({
2843
+ snmpConfigs: [coreRoutersConfig(), ...storedSnmpConfigs()],
2844
+ });
2845
+
2846
+ expect(writes).toHaveLength(1);
2847
+ expect(writes[0]!.data["status"]).toBe("Pending");
2848
+ });
2849
+
2850
+ /*
2851
+ * And the assertion the whole fixture is built around, restated for a
2852
+ * multi-config scan: re-posting the same list in the same order is not a
2853
+ * change. ModelForm posts every field it declares on every save, so this is
2854
+ * what an operator who opens Edit and presses Save actually sends — and if
2855
+ * it read as a change, every such visit would delete the scan's results.
2856
+ */
2857
+ it("does not retire the run when the same multi-config list is re-posted", async () => {
2858
+ storedScans = [
2859
+ storedScan({
2860
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
2861
+ }),
2862
+ ];
2863
+
2864
+ const writes: Array<ReconcileWrite> = await saveSettings({
2865
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
2866
+ });
2867
+
2868
+ expect(writes).toEqual([]);
2869
+ });
2870
+ });
2871
+
2872
+ /*
2873
+ * The same invariant on the EDIT path (issues #3444 + #3445).
2874
+ *
2875
+ * A scan's method became editable when its settings did, so the create hook is
2876
+ * no longer the only way a row can end up saying "sends no SNMP" while carrying
2877
+ * a community string. The edit form posts every declared field regardless of
2878
+ * visibility, exactly as the wizard does, so an operator who switches the
2879
+ * method off is still sending the credentials they just hid.
2880
+ */
2881
+ describe("NetworkDeviceDiscoveryScanService SNMP config on update", () => {
2882
+ it("clears the credentials when an edit turns the method off", async () => {
2883
+ const data: Record<string, unknown> = {
2884
+ isSnmpEnabled: false,
2885
+ snmpVersion: "V3",
2886
+ snmpCommunityString: "public",
2887
+ snmpPort: 1161,
2888
+ snmpV3Username: "monitoring",
2889
+ snmpV3AuthKey: "authentication passphrase",
2890
+ snmpV3PrivKey: "privacy passphrase",
2891
+ };
2892
+
2893
+ await saveSettings(data);
2894
+
2895
+ for (const column of [
2896
+ "snmpVersion",
2897
+ "snmpCommunityString",
2898
+ "snmpPort",
2899
+ "snmpV3SecurityLevel",
2900
+ "snmpV3Username",
2901
+ "snmpV3AuthProtocol",
2902
+ "snmpV3AuthKey",
2903
+ "snmpV3PrivProtocol",
2904
+ "snmpV3PrivKey",
2905
+ ]) {
2906
+ expect({ column: column, value: data[column] }).toEqual({
2907
+ column: column,
2908
+ value: null,
2909
+ });
2910
+ }
2911
+
2912
+ // The method itself is the one SNMP-ish key the clear must not touch.
2913
+ expect(data["isSnmpEnabled"]).toBe(false);
2914
+ });
2915
+
2916
+ it("leaves the credentials alone when an edit turns the method back on", async () => {
2917
+ const data: Record<string, unknown> = {
2918
+ isSnmpEnabled: true,
2919
+ snmpCommunityString: "public",
2920
+ };
2921
+
2922
+ await saveSettings(data);
2923
+
2924
+ expect(data["snmpCommunityString"]).toBe("public");
2925
+ });
2926
+
2927
+ /*
2928
+ * The guard is on the key being WRITTEN, not on the stored value. A rename,
2929
+ * or the probe-ingest endpoints writing run state, must not reach in and null
2930
+ * columns they never mentioned — and those writers do not read the row at
2931
+ * all, so a clear here would be a write nobody asked for.
2932
+ */
2933
+ it("leaves the credentials alone on an edit that never mentions the method", async () => {
2934
+ const data: Record<string, unknown> = {
2935
+ snmpCommunityString: "public",
2936
+ };
2937
+
2938
+ await saveSettings(data);
2939
+
2940
+ expect(data["snmpCommunityString"]).toBe("public");
2941
+ });
2942
+ });
2943
+
2944
+ /*
2945
+ * ---------------------------------------------------------------------------
2946
+ * WHERE THE TWO CHANGES MEET, ON UPDATE (issues #3445 + #3458 together).
2947
+ * ---------------------------------------------------------------------------
2948
+ *
2949
+ * The same collision as on the create path, arriving through a different door:
2950
+ * a scan's method became editable when its settings did, so an operator can
2951
+ * untick "Check SNMP on hosts that answer" on a scan that already HAS a
2952
+ * credential list, with the list editor's cards still filled in behind the
2953
+ * hidden step.
2954
+ *
2955
+ * The update hook answers it as a strict either/or. A save that WRITES
2956
+ * `isSnmpEnabled`, and writes it false, clears the whole SNMP set — the list
2957
+ * first — and `applySnmpConfigs` is skipped outright. That skip is not an
2958
+ * optimisation: applySnmpConfigs is the pass that VALIDATES the list, and
2959
+ * ModelForm posts every declared field regardless of visibility, so running it
2960
+ * would refuse the operator's save over the very credentials they had just told
2961
+ * the product to stop using. The other branch is unchanged — a save that says
2962
+ * nothing about the method still validates and mirrors its list exactly as it
2963
+ * did before the method column existed.
2964
+ */
2965
+ describe("NetworkDeviceDiscoveryScanService: turning SNMP off on a scan that has a credential list", () => {
2966
+ /*
2967
+ * The full set, from the payload the edit form actually sends: the toggle
2968
+ * off, and behind it the two-config list the scan was carrying a moment ago.
2969
+ * Ten columns, not nine — the list is where those credentials live now.
2970
+ */
2971
+ it("clears the credential list along with the nine flattened columns", async () => {
2972
+ const data: Record<string, unknown> = {
2973
+ isSnmpEnabled: false,
2974
+ snmpConfigs: [coreRoutersConfig(), ...storedSnmpConfigs()],
2975
+ snmpVersion: "V3",
2976
+ snmpCommunityString: "public",
2977
+ snmpPort: 1161,
2978
+ snmpV3SecurityLevel: "authPriv",
2979
+ snmpV3Username: "netops",
2980
+ snmpV3AuthProtocol: "SHA",
2981
+ snmpV3AuthKey: "auth-secret",
2982
+ snmpV3PrivProtocol: "AES",
2983
+ snmpV3PrivKey: "priv-secret",
2984
+ };
2985
+
2986
+ await saveSettings(data);
2987
+
2988
+ for (const column of EVERY_SNMP_COLUMN) {
2989
+ expect({ column: column, value: data[column] }).toEqual({
2990
+ column: column,
2991
+ value: null,
2992
+ });
2993
+ }
2994
+
2995
+ // The method itself is the one SNMP-ish key the clear must not touch.
2996
+ expect(data["isSnmpEnabled"]).toBe(false);
2997
+ });
2998
+
2999
+ /*
3000
+ * THE REASON THE SKIP EXISTS, and the case that turns a merge of two
3001
+ * independently-correct changes into a bug if the order is wrong.
3002
+ *
3003
+ * Every list shape that is a 400 on any other update — a bare string, an
3004
+ * empty list, a v3 config with no key — has to sail through this one. The
3005
+ * operator has told the product to stop sending SNMP; refusing their save
3006
+ * because the credentials they abandoned are half-typed would leave them
3007
+ * stuck on a form whose offending field the wizard has taken off the screen,
3008
+ * with no way to make the error go away except to fill in credentials for a
3009
+ * sweep that will never send them.
3010
+ *
3011
+ * Asserted together with the stored value for the same reason as on the
3012
+ * create path: a hook that validated first and cleared afterwards would still
3013
+ * store null, and a hook that skipped validation but kept the list would
3014
+ * still not throw.
3015
+ */
3016
+ it("is not refused when the hidden SNMP step posts an invalid or empty list", async () => {
3017
+ for (const [label, value] of INVALID_LISTS) {
3018
+ const data: Record<string, unknown> = {
3019
+ isSnmpEnabled: false,
3020
+ snmpConfigs: value,
3021
+ };
3022
+
3023
+ const error: Error | null = await captureError((): Promise<unknown> => {
3024
+ return onBeforeUpdate(makeUpdateBy(data));
3025
+ });
3026
+
3027
+ expect({
3028
+ posted: label,
3029
+ error: error === null ? null : error.message,
3030
+ snmpConfigs: data["snmpConfigs"],
3031
+ }).toEqual({
3032
+ posted: label,
3033
+ error: null,
3034
+ snmpConfigs: null,
3035
+ });
3036
+ }
3037
+ });
3038
+
3039
+ /*
3040
+ * ...while the SAME list on a save that turns the method back ON is refused
3041
+ * exactly as it was before. The skip is conditional on the operator having
3042
+ * turned SNMP off, not a hole in the validation — and this is the one
3043
+ * payload where they really do need to hear about it, because they have just
3044
+ * asked for a sweep that will use these credentials.
3045
+ */
3046
+ it("still refuses an invalid list on a save that turns the method back on", async () => {
3047
+ const error: Error | null = await captureError((): Promise<unknown> => {
3048
+ return onBeforeUpdate(
3049
+ makeUpdateBy({ isSnmpEnabled: true, snmpConfigs: [] }),
3050
+ );
3051
+ });
3052
+
3053
+ expect(error).toBeInstanceOf(BadDataException);
3054
+ expect(error?.message).toBe(SnmpScanConfigUtil.getValidationError([]));
3055
+ });
3056
+
3057
+ /*
3058
+ * The method is a sweep column like any other, so turning it off retires the
3059
+ * run — the same reconciliation the credential list gets above.
3060
+ *
3061
+ * The hosts on the row answered a DIFFERENT question. Every one of them was
3062
+ * asked for its SNMP system group, and the ones that answered are on the row
3063
+ * as polled devices with a vendor and a sysName; the sweep this save
3064
+ * describes asks nothing of the kind. Left alone, the Review Results dialog
3065
+ * would go on offering them for import under a scan that no longer collects
3066
+ * any of it, and the auto-import worker would create polled devices from a
3067
+ * ping sweep's results.
3068
+ */
3069
+ it("retires the run and clears the results, because a sweep that stops asking for SNMP is a different sweep", async () => {
3070
+ const writes: Array<ReconcileWrite> = await saveSettings({
3071
+ ...unchangedSave(),
3072
+ isSnmpEnabled: false,
3073
+ });
3074
+
3075
+ expect(writes).toHaveLength(1);
3076
+ expect(writes[0]!.data).toEqual({
3077
+ status: "Pending",
3078
+ statusMessage: expect.stringContaining("queued to run again"),
3079
+ startedAt: null,
3080
+ completedAt: null,
3081
+ nextScanAt: null,
3082
+ discoveredDevices: null,
3083
+ scannedHostCount: null,
3084
+ respondedHostCount: null,
3085
+ autoImportProcessedAt: null,
3086
+ });
3087
+ });
3088
+
3089
+ /*
3090
+ * And the no-op that every retirement rule has to survive: a scan that is
3091
+ * ALREADY ICMP-only, saved again with the emptied SNMP set it is carrying.
3092
+ *
3093
+ * The clearing above writes nulls; the form posts empty strings; the stored
3094
+ * row holds a mixture of nulls and columns that were never written at all.
3095
+ * All three mean the same thing — "this scan has no SNMP settings" — and the
3096
+ * sweep comparison has to say so, or every save on a ping-only scan would
3097
+ * delete the hosts it had found, silently, with the operator having changed
3098
+ * nothing. That is one normalizer answering for a nulled list, an unset
3099
+ * column and an empty box at once, which is exactly the seam where the two
3100
+ * changes meet.
3101
+ *
3102
+ * The pre-image deliberately does NOT carry `isSnmpEnabled`, and this payload
3103
+ * deliberately does not write it: the row this hook reads is built from an
3104
+ * explicit `select` that does not ask for that column, so a fixture that set
3105
+ * it would be asserting against a row shape the service never sees. See the
3106
+ * note in the handover about what that costs on the payload the Edit dialog
3107
+ * actually sends.
3108
+ */
3109
+ it("does not retire the run when a scan that already sends no SNMP is re-saved", async () => {
3110
+ storedScans = [
3111
+ storedScan({
3112
+ snmpConfigs: null,
3113
+ snmpVersion: undefined,
3114
+ snmpCommunityString: null,
3115
+ snmpPort: undefined,
3116
+ }),
3117
+ ];
3118
+
3119
+ const writes: Array<ReconcileWrite> = await saveSettings({
3120
+ name: "Region 1100",
3121
+ cidr: "192.168.1.0/24",
3122
+ probe: { _id: PROBE_ID.toString() },
3123
+ /*
3124
+ * What the toggle's onChange left in the form values, posted on the way
3125
+ * past even though the SNMP step is not on screen: the list cleared
3126
+ * outright, the flattened boxes empty.
3127
+ */
3128
+ snmpConfigs: null,
3129
+ snmpVersion: "",
3130
+ snmpCommunityString: "",
3131
+ snmpPort: "",
3132
+ snmpV3SecurityLevel: "",
3133
+ snmpV3Username: "",
3134
+ snmpV3AuthProtocol: "",
3135
+ snmpV3AuthKey: "",
3136
+ snmpV3PrivProtocol: "",
3137
+ snmpV3PrivKey: "",
3138
+ isRecurring: false,
3139
+ rescanIntervalInMinutes: null,
3140
+ });
3141
+
3142
+ expect(writes).toEqual([]);
3143
+ });
3144
+ });
3145
+
3146
+ /*
3147
+ * ---------------------------------------------------------------------------
3148
+ * WHAT ACTUALLY COUNTS AS A CREDENTIAL CHANGE (issue #3458, review round).
3149
+ * ---------------------------------------------------------------------------
3150
+ *
3151
+ * `hasSweepChanged` decides whether a save retires the run — status back to
3152
+ * Pending, statusMessage rewritten, and every host the scan had found deleted
3153
+ * from `discoveredDevices`. For the credential LIST that decision is made by
3154
+ * `normalizeSweepValue`, and it used to compare the whole stored object. Two
3155
+ * of the fields on that object are not part of the sweep at all, and each one
3156
+ * turned an operation that changes nothing about what the probe sends into a
3157
+ * silent deletion of the scan's results.
3158
+ *
3159
+ * - `id` is server-minted bookkeeping. `applySnmpConfigs` runs BEFORE the
3160
+ * comparison and mints a fresh ObjectID for every entry that arrived
3161
+ * without one, so any client that does not echo ids back — a plain API
3162
+ * caller, an IaC reconciler re-applying the same desired state — posted a
3163
+ * byte-identical body twice and had the SECOND one read as a credential
3164
+ * change. A scan under such a reconciler could never hold a result set at
3165
+ * all: every reconcile loop re-queued it.
3166
+ * - `name` is the operator's label on a credential card. It reaches a log
3167
+ * line and the scan's status message and nothing else — never the wire —
3168
+ * so renaming a card retired the run and deleted every host the scan had
3169
+ * found.
3170
+ *
3171
+ * The fix strips exactly those two before comparing. The tests below pin both
3172
+ * halves of that: the two fields must NOT count, and everything the probe
3173
+ * actually puts on the wire — version, community, port, the v3 block, and the
3174
+ * ORDER the configs are tried in — must still count.
3175
+ */
3176
+
3177
+ /*
3178
+ * The same two credential sets the fixture's stored row holds, posted the way
3179
+ * a client that does not track server-minted ids sends them: no `id` on either
3180
+ * entry.
3181
+ *
3182
+ * This is not a contrived shape. `id` is optional on the wire precisely so a
3183
+ * caller can express "these are my credentials" without having to read the row
3184
+ * back first, which is exactly what a reconciler does on every loop.
3185
+ */
3186
+ function idLessConfigs(): Array<DiscoveryScanSnmpConfig> {
3187
+ return [
3188
+ {
3189
+ name: "Access switches",
3190
+ snmpVersion: "V2c",
3191
+ snmpCommunityString: "public",
3192
+ snmpPort: 161,
3193
+ },
3194
+ {
3195
+ name: "Core routers",
3196
+ snmpVersion: "V3",
3197
+ snmpPort: 1161,
3198
+ snmpV3SecurityLevel: "authPriv",
3199
+ snmpV3Username: "netops",
3200
+ snmpV3AuthProtocol: "SHA",
3201
+ snmpV3AuthKey: "auth-secret",
3202
+ snmpV3PrivProtocol: "AES",
3203
+ snmpV3PrivKey: "priv-secret",
3204
+ },
3205
+ ];
3206
+ }
3207
+
3208
+ /*
3209
+ * One field of one config, changed to something the probe would put on the
3210
+ * wire differently. Every one of these has to keep retiring the run: the hosts
3211
+ * on the row were found with the OLD value, and a community string or a v3 key
3212
+ * the scan no longer uses cannot have found them.
3213
+ *
3214
+ * The v1/v2c fields are changed on the first card and the v3 block on the
3215
+ * second, so that each posted config stays valid on its own — a v2c card
3216
+ * switched to v3 with no username is a 400, not a credential change, and would
3217
+ * prove nothing about the comparison.
3218
+ *
3219
+ * `whichConfig` names the card so a failure inside the loop says which one it
3220
+ * was, which is the whole reason these are a table rather than nine tests.
3221
+ */
3222
+ type CredentialChangeCase = [
3223
+ label: string,
3224
+ whichConfig: "the v2c card" | "the v3 card",
3225
+ change: DiscoveryScanSnmpConfig,
3226
+ ];
3227
+
3228
+ const REAL_CREDENTIAL_CHANGES: Array<CredentialChangeCase> = [
3229
+ ["the community string", "the v2c card", { snmpCommunityString: "private" }],
3230
+ ["the port", "the v2c card", { snmpPort: 1161 }],
3231
+ ["the SNMP version", "the v2c card", { snmpVersion: "V1" }],
3232
+ ["the v3 username", "the v3 card", { snmpV3Username: "netops-readonly" }],
3233
+ [
3234
+ "the v3 security level",
3235
+ "the v3 card",
3236
+ { snmpV3SecurityLevel: "authNoPriv" },
3237
+ ],
3238
+ ["the v3 auth protocol", "the v3 card", { snmpV3AuthProtocol: "MD5" }],
3239
+ ["the v3 auth key", "the v3 card", { snmpV3AuthKey: "rotated-auth-secret" }],
3240
+ ["the v3 priv protocol", "the v3 card", { snmpV3PrivProtocol: "DES" }],
3241
+ ["the v3 priv key", "the v3 card", { snmpV3PrivKey: "rotated-priv-secret" }],
3242
+ ];
3243
+
3244
+ describe("NetworkDeviceDiscoveryScanService: what counts as a credential change", () => {
3245
+ /*
3246
+ * THE RECONCILER LOOP, run for real rather than described.
3247
+ *
3248
+ * Two saves of the SAME id-less body. The first is a genuine change — a
3249
+ * second credential set appears — so it retires the run, which is correct.
3250
+ * The second is byte-identical to the first and must be a no-op.
3251
+ *
3252
+ * In between, the server has minted ids onto the stored row (asserted, so
3253
+ * this test cannot pass by the ids never being minted at all), which is the
3254
+ * whole trap: the comparison sees a stored list carrying two ObjectIDs
3255
+ * against a posted list carrying two freshly-minted DIFFERENT ObjectIDs. If
3256
+ * `id` counted, the second save would retire the run and delete the hosts —
3257
+ * and so would the third, and the fourth, forever.
3258
+ */
3259
+ it("does not retire the run when a client that never echoes ids back posts the same list twice", async () => {
3260
+ const writesAfterFirstSave: Array<ReconcileWrite> = await saveSettings({
3261
+ ...unchangedSave(),
3262
+ snmpConfigs: idLessConfigs(),
3263
+ });
3264
+
3265
+ expect(writesAfterFirstSave).toHaveLength(1);
3266
+ expect(writesAfterFirstSave[0]!.data["status"]).toBe("Pending");
3267
+
3268
+ const writeCountAfterFirstSave: number = writesAfterFirstSave.length;
3269
+
3270
+ /*
3271
+ * The row now holds server-minted ids for entries that arrived with none.
3272
+ * That is the state the second save has to compare against.
3273
+ */
3274
+ const storedAfterFirstSave: Array<DiscoveryScanSnmpConfig> = storedScans[0]!
3275
+ .snmpConfigs as Array<DiscoveryScanSnmpConfig>;
3276
+
3277
+ expect(storedAfterFirstSave).toHaveLength(2);
3278
+
3279
+ for (const config of storedAfterFirstSave) {
3280
+ expect({ hasMintedId: Boolean(config.id) }).toEqual({
3281
+ hasMintedId: true,
3282
+ });
3283
+ }
3284
+
3285
+ const writesAfterSecondSave: Array<ReconcileWrite> = await saveSettings({
3286
+ ...unchangedSave(),
3287
+ snmpConfigs: idLessConfigs(),
3288
+ });
3289
+
3290
+ expect(writesAfterSecondSave.slice(writeCountAfterFirstSave)).toEqual([]);
3291
+ });
3292
+
3293
+ /*
3294
+ * The label on a credential card is for the operator reading the list. It
3295
+ * never reaches an SNMP packet, so renaming "Access switches" to "Edge
3296
+ * switches" cannot have invalidated a single host the scan found.
3297
+ *
3298
+ * Everything else in this payload is the stored row posted back verbatim,
3299
+ * which is what ModelForm sends on every save — so a write here would mean
3300
+ * the rename, and nothing else, deleted the results.
3301
+ */
3302
+ it("does not retire the run when only a config's operator label changes", async () => {
3303
+ const renamedConfigs: Array<DiscoveryScanSnmpConfig> =
3304
+ storedSnmpConfigs().map(
3305
+ (config: DiscoveryScanSnmpConfig): DiscoveryScanSnmpConfig => {
3306
+ return { ...config, name: "Edge switches" };
3307
+ },
3308
+ );
3309
+
3310
+ const writes: Array<ReconcileWrite> = await saveSettings({
3311
+ ...unchangedSave(),
3312
+ snmpConfigs: renamedConfigs,
3313
+ });
3314
+
3315
+ expect(writes).toEqual([]);
3316
+ });
3317
+
3318
+ /*
3319
+ * The other side of the same line: the fields that DO reach the wire still
3320
+ * retire the run, one at a time.
3321
+ *
3322
+ * ISOLATION IS THE POINT OF THE FIXTURE HERE. Each case posts the whole
3323
+ * unchanged form — which names all nine flattened SNMP columns, so
3324
+ * `applySnmpConfigs` mirrors nothing over them ("the payload wins where it
3325
+ * speaks for itself") and their values stay exactly what the stored row
3326
+ * holds. The credential LIST is therefore the only thing in the payload that
3327
+ * differs, and a retirement here can have no other cause.
3328
+ *
3329
+ * Without that, "stop comparing id and name" could be simplified all the way
3330
+ * into "stop comparing the list", the mirrored flattened columns would flag
3331
+ * the change instead, and every one of these would still pass while the
3332
+ * multi-config case they exist for went unguarded.
3333
+ *
3334
+ * The fixture is rebuilt per case because a save mutates the stored row.
3335
+ */
3336
+ it("still retires the run when a real session parameter changes", async () => {
3337
+ for (const [label, whichConfig, change] of REAL_CREDENTIAL_CHANGES) {
3338
+ storedScans = [
3339
+ storedScan({
3340
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
3341
+ }),
3342
+ ];
3343
+ reconcileWrites = [];
3344
+
3345
+ const postedConfigs: Array<DiscoveryScanSnmpConfig> =
3346
+ whichConfig === "the v2c card"
3347
+ ? [{ ...storedSnmpConfigs()[0]!, ...change }, coreRoutersConfig()]
3348
+ : [storedSnmpConfigs()[0]!, { ...coreRoutersConfig(), ...change }];
3349
+
3350
+ const writes: Array<ReconcileWrite> = await saveSettings({
3351
+ ...unchangedSave(),
3352
+ snmpConfigs: postedConfigs,
3353
+ });
3354
+
3355
+ /*
3356
+ * The cleared results are asserted as a boolean rather than read out of
3357
+ * the payload, because the value they are cleared TO is null — and a
3358
+ * `?? fallback` on the way to the assertion would report an untouched
3359
+ * payload and a cleared one identically.
3360
+ */
3361
+ expect({
3362
+ changed: label,
3363
+ on: whichConfig,
3364
+ status: writes[0]?.data["status"] ?? "no write at all",
3365
+ clearedTheHostsItFound:
3366
+ writes[0] !== undefined &&
3367
+ writes[0].data["discoveredDevices"] === null,
3368
+ }).toEqual({
3369
+ changed: label,
3370
+ on: whichConfig,
3371
+ status: "Pending",
3372
+ clearedTheHostsItFound: true,
3373
+ });
3374
+ }
3375
+ });
3376
+
3377
+ /*
3378
+ * Order is a sweep parameter, and dropping `id` from the comparison is
3379
+ * exactly the change that could have hidden it — before the fix, two
3380
+ * reordered entries were told apart by their ids as much as by anything
3381
+ * else.
3382
+ *
3383
+ * It matters because order decides which credential each host is tried with
3384
+ * FIRST, and because the first entry is the one mirrored onto the flattened
3385
+ * columns that every older probe in the fleet reads. A scan whose preferred
3386
+ * credential moved can find a different set of hosts.
3387
+ *
3388
+ * Posted without ids, so the comparison has nothing but the session
3389
+ * parameters and their order to go on — and posted alongside the row's own
3390
+ * flattened columns, unchanged, for the same isolation reason as the table
3391
+ * above: a payload that names those columns suppresses the mirror, so the
3392
+ * REORDER cannot be detected second-hand through a mirrored first config
3393
+ * that happens to have moved.
3394
+ */
3395
+ it("still retires the run when an id-less list is only reordered", async () => {
3396
+ storedScans = [
3397
+ storedScan({
3398
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
3399
+ }),
3400
+ ];
3401
+
3402
+ const reorderedConfigs: Array<DiscoveryScanSnmpConfig> =
3403
+ idLessConfigs().reverse();
3404
+
3405
+ const writes: Array<ReconcileWrite> = await saveSettings({
3406
+ ...unchangedSave(),
3407
+ snmpConfigs: reorderedConfigs,
3408
+ });
3409
+
3410
+ expect(writes).toHaveLength(1);
3411
+ expect(writes[0]!.data["status"]).toBe("Pending");
3412
+ });
3413
+
3414
+ /*
3415
+ * Deleting a credential card is the counterpart of adding one, pinned above.
3416
+ * The hosts the removed config found are still on the row, attributed to a
3417
+ * credential set the scan no longer has — the import path would fall back to
3418
+ * the first config and create devices that can never poll.
3419
+ *
3420
+ * The removed card is the SECOND one, so the flattened columns mirrored from
3421
+ * the first are unaffected either way — and they are posted unchanged on top
3422
+ * of that. The shortened list is the only difference in the payload.
3423
+ */
3424
+ it("still retires the run when a config is removed from the list", async () => {
3425
+ storedScans = [
3426
+ storedScan({
3427
+ snmpConfigs: [...storedSnmpConfigs(), coreRoutersConfig()],
3428
+ }),
3429
+ ];
3430
+
3431
+ const writes: Array<ReconcileWrite> = await saveSettings({
3432
+ ...unchangedSave(),
3433
+ snmpConfigs: [storedSnmpConfigs()[0]!],
3434
+ });
3435
+
3436
+ expect(writes).toHaveLength(1);
3437
+ expect(writes[0]!.data["status"]).toBe("Pending");
3438
+ });
3439
+ });
3440
+
3441
+ /*
3442
+ * ---------------------------------------------------------------------------
3443
+ * THE LIST IS NEVER NULLED OUT FROM UNDER A SCAN (issue #3458, review round).
3444
+ * ---------------------------------------------------------------------------
3445
+ *
3446
+ * `reconcileLegacySnmpUpdate` used to answer a save written through the
3447
+ * flattened columns alone by NULLING `snmpConfigs`, on the reasoning that the
3448
+ * list would otherwise go on shadowing the columns the caller had just
3449
+ * written.
3450
+ *
3451
+ * That left the row in the one state the product cannot survive: credentials
3452
+ * that are READABLE but not EDITABLE. `SnmpScanConfigUtil.resolve` falls back
3453
+ * to the flattened columns, so the probe kept sweeping correctly and nothing
3454
+ * looked wrong — but the Edit dialog's only SNMP control is the list editor,
3455
+ * and it selects `snmpConfigs` and nothing else. It therefore opened a BLANK
3456
+ * credential card over a scan holding a real community string, and saving that
3457
+ * card mirrored its emptiness back over all nine flattened columns and wiped
3458
+ * them.
3459
+ *
3460
+ * The fix keeps the list: the reconciliation now reports that a legacy write
3461
+ * happened, and `onUpdateSuccess` REBUILDS the list from the row's own
3462
+ * flattened columns once the write has landed.
3463
+ */
3464
+ describe("NetworkDeviceDiscoveryScanService: a legacy save leaves the list rebuilt, never null", () => {
3465
+ /*
3466
+ * A whole v3 credential set written through the flattened columns — nine
3467
+ * columns at once, which is what an integration written before the list
3468
+ * existed actually sends.
3469
+ *
3470
+ * The rebuilt list must describe those NINE values, not the v2c community
3471
+ * string the row's list held a moment ago: a list still holding "public"
3472
+ * would shadow the v3 credentials the caller was told had been stored.
3473
+ *
3474
+ * Asserted alongside the retirement, because this save really does change
3475
+ * the sweep and both writes land in the same statement.
3476
+ */
3477
+ it("rebuilds the whole v3 credential set a legacy save wrote, rather than nulling the list", async () => {
3478
+ storedScans = [storedScan()];
3479
+
3480
+ const writes: Array<ReconcileWrite> = await saveSettings({
3481
+ snmpVersion: "V3",
3482
+ snmpCommunityString: null,
3483
+ snmpPort: 1161,
3484
+ snmpV3SecurityLevel: "authPriv",
3485
+ snmpV3Username: "netops",
3486
+ snmpV3AuthProtocol: "SHA",
3487
+ snmpV3AuthKey: "auth-secret",
3488
+ snmpV3PrivProtocol: "AES",
3489
+ snmpV3PrivKey: "priv-secret",
3490
+ });
3491
+
3492
+ expect(writes).toHaveLength(1);
3493
+ expect(writes[0]!.data["status"]).toBe("Pending");
3494
+
3495
+ /*
3496
+ * The synthetic legacy id, and no `name` — a save through these columns
3497
+ * has no way to express a label, and inventing one would be the rebuild
3498
+ * claiming to know something the caller never sent.
3499
+ */
3500
+ expect(writes[0]!.data["snmpConfigs"]).toEqual([
3501
+ {
3502
+ id: LEGACY_SNMP_CONFIG_ID,
3503
+ snmpVersion: "V3",
3504
+ snmpPort: 1161,
3505
+ snmpV3SecurityLevel: "authPriv",
3506
+ snmpV3Username: "netops",
3507
+ snmpV3AuthProtocol: "SHA",
3508
+ snmpV3AuthKey: "auth-secret",
3509
+ snmpV3PrivProtocol: "AES",
3510
+ snmpV3PrivKey: "priv-secret",
3511
+ },
3512
+ ]);
3513
+ });
3514
+
3515
+ /*
3516
+ * THE INVARIANT, stated directly rather than implied by the paths above.
3517
+ *
3518
+ * Every way the PRODUCT can write an SNMP scan has to leave it holding a
3519
+ * credential list, because that is the single assumption the Edit dialog
3520
+ * makes: it seeds its list editor from `snmpConfigs` alone, so any path that
3521
+ * can leave the column null is a path that can hand an operator a blank card
3522
+ * over live credentials and let them save it away.
3523
+ *
3524
+ * Three writers maintain it — the backfill migration, `onBeforeCreate`, and
3525
+ * (since the fix) `onUpdateSuccess` — and the fourth, `onBeforeUpdate` with
3526
+ * a list in the payload, simply stores what was sent. This walks all of them
3527
+ * in one test so that adding a fifth without maintaining the invariant fails
3528
+ * here rather than in the dialog.
3529
+ *
3530
+ * The reader is deliberately NOT the thing under test: resolve() falls back
3531
+ * to the flattened columns and would answer happily for a null list, which
3532
+ * is precisely why the defect was invisible in production.
3533
+ */
3534
+ it("never leaves an SNMP scan with a null credential list, whichever way the product writes it", async () => {
3535
+ const createdFromFlattenedColumns: Record<string, unknown> =
3536
+ await createdRow({
3537
+ snmpVersion: "V2c",
3538
+ snmpCommunityString: "public",
3539
+ snmpPort: 161,
3540
+ });
3541
+
3542
+ const createdFromTheListItself: Record<string, unknown> = await createdRow({
3543
+ snmpConfigs: [coreRoutersConfig()],
3544
+ });
3545
+
3546
+ const createdWithNoSnmpSettingsAtAll: Record<string, unknown> =
3547
+ await createdRow({});
3548
+
3549
+ storedScans = [storedScan()];
3550
+ reconcileWrites = [];
3551
+
3552
+ const savedThroughTheEditForm: Record<string, unknown> = await updatedRow({
3553
+ ...unchangedSave(),
3554
+ snmpConfigs: [coreRoutersConfig()],
3555
+ });
3556
+
3557
+ storedScans = [storedScan()];
3558
+ reconcileWrites = [];
3559
+
3560
+ const legacyWrites: Array<ReconcileWrite> = await saveSettings({
3561
+ snmpCommunityString: "private",
3562
+ });
3563
+
3564
+ /*
3565
+ * For the legacy path the final value is the one the reconciling write
3566
+ * put back, because the payload itself never mentioned the column.
3567
+ */
3568
+ const finalListPerWritePath: Array<[string, unknown]> = [
3569
+ [
3570
+ "created from the flattened columns",
3571
+ createdFromFlattenedColumns["snmpConfigs"],
3572
+ ],
3573
+ ["created from the list itself", createdFromTheListItself["snmpConfigs"]],
3574
+ [
3575
+ "created with no SNMP settings at all",
3576
+ createdWithNoSnmpSettingsAtAll["snmpConfigs"],
3577
+ ],
3578
+ ["saved through the edit form", savedThroughTheEditForm["snmpConfigs"]],
3579
+ [
3580
+ "saved through the flattened columns alone",
3581
+ legacyWrites[0]?.data["snmpConfigs"],
3582
+ ],
3583
+ ];
3584
+
3585
+ for (const [path, storedList] of finalListPerWritePath) {
3586
+ expect({
3587
+ path: path,
3588
+ holdsACredentialList:
3589
+ Array.isArray(storedList) && storedList.length > 0,
3590
+ }).toEqual({
3591
+ path: path,
3592
+ holdsACredentialList: true,
3593
+ });
3594
+ }
3595
+ });
3596
+ });