@oneuptime/common 12.0.34 → 13.0.0

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 (259) hide show
  1. package/Models/DatabaseModels/GlobalConfig.ts +12 -12
  2. package/Models/DatabaseModels/InventoryItem.ts +44 -1
  3. package/Models/DatabaseModels/NetworkDevice.ts +126 -0
  4. package/Server/EnvironmentConfig.ts +47 -16
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.ts +25 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Infrastructure/Redis.ts +8 -8
  8. package/Server/Infrastructure/Status.ts +10 -4
  9. package/Server/Services/DatabaseService.ts +65 -13
  10. package/Server/Services/InventoryItemService.ts +31 -1
  11. package/Server/Services/MonitorTemplateService.ts +140 -19
  12. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +349 -41
  13. package/Server/Services/NetworkDeviceDiscoveryScanService.ts +87 -2
  14. package/Server/Services/NetworkDeviceService.ts +69 -0
  15. package/Server/Services/UserNotificationRuleService.ts +76 -25
  16. package/Server/Types/Database/Permissions/ReadPermission.ts +45 -5
  17. package/Server/Types/Database/QueryUtil.ts +56 -0
  18. package/Server/Types/Markdown.ts +93 -1
  19. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +82 -17
  20. package/Server/Utils/EmailRollup/EmailRollupFlushRunner.ts +10 -1
  21. package/Server/Utils/EmailRollup/EmailRollupRenderer.ts +2 -2
  22. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +82 -35
  23. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +31 -5
  24. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +202 -37
  25. package/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.ts +82 -9
  26. package/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.ts +16 -7
  27. package/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.ts +29 -17
  28. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +107 -48
  29. package/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.ts +158 -0
  30. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +27 -0
  31. package/Server/Utils/Telemetry/EntityRegistry.ts +11 -1
  32. package/Server/Utils/Telemetry/TelemetryEntity.ts +83 -9
  33. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +32 -0
  34. package/Tests/App/Dashboard/AdoptedDeviceDrawer.test.tsx +374 -0
  35. package/Tests/App/Dashboard/DashboardLabelVariables.test.tsx +580 -0
  36. package/Tests/App/Dashboard/DashboardMonitorLabelVariable.test.tsx +438 -0
  37. package/Tests/App/Dashboard/DashboardVariableAllContract.test.tsx +5 -2
  38. package/Tests/App/Dashboard/DeviceAttachmentCard.test.tsx +273 -0
  39. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +505 -0
  40. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +163 -0
  41. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +401 -0
  42. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +232 -0
  43. package/Tests/App/Dashboard/InventoryItems.test.tsx +201 -0
  44. package/Tests/App/Dashboard/InventoryTypeAndStatusFacets.test.tsx +798 -0
  45. package/Tests/App/Dashboard/NetworkTopologyLiveView.test.tsx +320 -0
  46. package/Tests/App/Dashboard/NetworkTopologyToolbar.test.tsx +275 -0
  47. package/Tests/App/Dashboard/OnCallCalendarFeedEmptyState.test.tsx +975 -0
  48. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +63 -0
  49. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +626 -0
  50. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +348 -0
  51. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +331 -0
  52. package/Tests/App/Dashboard/UserSettingsEmailPreferences.test.tsx +603 -0
  53. package/Tests/App/Dashboard/UserSettingsNotificationSettings.test.tsx +324 -0
  54. package/Tests/App/PublicDashboard/DashboardVariableSelector.test.tsx +13 -4
  55. package/Tests/App/StatusPage/StatusPageOidcOrigin.test.tsx +223 -0
  56. package/Tests/Models/InventoryItemStatus.test.ts +147 -0
  57. package/Tests/Models/NetworkDeviceMacAddressColumn.test.ts +294 -0
  58. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +129 -0
  59. package/Tests/Server/EnvironmentConfigValkey.test.ts +227 -0
  60. package/Tests/Server/Infrastructure/Postgres/AddMacAddressToNetworkDeviceMigration.test.ts +365 -0
  61. package/Tests/Server/Infrastructure/Postgres/CustomFieldValueMappingMigration.test.ts +62 -23
  62. package/Tests/Server/Infrastructure/ValkeyDeploymentConfig.test.ts +265 -0
  63. package/Tests/Server/Middleware/HttpMetricsMiddleware.test.ts +382 -0
  64. package/Tests/Server/Middleware/SCIMAuthorization.test.ts +525 -0
  65. package/Tests/Server/Services/DatabaseServiceAggregateBy.test.ts +291 -56
  66. package/Tests/Server/Services/DatabaseServiceSortTiebreaker.test.ts +151 -0
  67. package/Tests/Server/Services/InventoryItemManualCreate.test.ts +105 -0
  68. package/Tests/Server/Services/InventoryItemPromotionGate.test.ts +178 -0
  69. package/Tests/Server/Services/MonitorTemplateServiceCustomFieldSync.test.ts +502 -0
  70. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +1133 -81
  71. package/Tests/Server/Services/NetworkDeviceDiscoveryScanRegistration.test.ts +520 -0
  72. package/Tests/Server/Services/NetworkDeviceMacAddressNormalization.test.ts +572 -0
  73. package/Tests/Server/Types/Database/InventoryStatusPostgres.test.ts +368 -0
  74. package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +261 -14
  75. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroups.test.ts +169 -0
  76. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroupsPostgres.test.ts +297 -0
  77. package/Tests/Server/Types/Markdown.test.ts +194 -1
  78. package/Tests/Server/Types/Workflow/Components/IncomingWebhookUtils.test.ts +279 -0
  79. package/Tests/Server/Types/Workflow/Components/TextToJson.test.ts +212 -0
  80. package/Tests/Server/Utils/CustomField/CustomFieldMappingRegistry.test.ts +436 -0
  81. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +214 -0
  82. package/Tests/Server/Utils/EmailRollup/EmailRollupFlushRunnerBehaviour.test.ts +1 -1
  83. package/Tests/Server/Utils/EmailRollup/EmailRollupRenderer.test.ts +10 -3
  84. package/Tests/Server/Utils/EventLoop.test.ts +359 -0
  85. package/Tests/Server/Utils/JsonWebToken.test.ts +30 -0
  86. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +242 -0
  87. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteriaAggregation.test.ts +13 -7
  88. package/Tests/Server/Utils/Monitor/Criteria/DatabaseMonitorCriteria.test.ts +29 -0
  89. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +30 -6
  90. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +246 -0
  91. package/Tests/Server/Utils/Monitor/MonitorCriteriaEmailEvalLogAgreement.test.ts +280 -0
  92. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +224 -10
  93. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +570 -0
  94. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilderUnits.test.ts +30 -5
  95. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageBuilderUnits.test.ts +68 -21
  96. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +36 -19
  97. package/Tests/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.test.ts +573 -0
  98. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +317 -0
  99. package/Tests/Server/Utils/Telemetry/ContextSpanProcessor.test.ts +281 -0
  100. package/Tests/Server/Utils/Telemetry/InventoryDuplicateIdentity.test.ts +316 -0
  101. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +111 -8
  102. package/Tests/Types/Database/IncludesAnyOfGroups.test.ts +106 -0
  103. package/Tests/Types/JSONFunctions.test.ts +40 -0
  104. package/Tests/Types/NetworkAutomation/AutoImportRunMerge.test.ts +235 -0
  105. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +66 -0
  106. package/Tests/Types/NetworkAutomation/RuleRunResultDescribe.test.ts +309 -1
  107. package/Tests/Types/Telemetry/InventoryLiveness.test.ts +99 -0
  108. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +160 -0
  109. package/Tests/UI/ReactRouterSingletonBuild.test.ts +133 -0
  110. package/Tests/UI/Rum/ReplayHeader.test.tsx +10 -2
  111. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +514 -0
  112. package/Tests/UI/Rum/ReplayTimeFormat.test.ts +94 -0
  113. package/Tests/UI/Rum/ReplayUi.test.tsx +559 -0
  114. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  115. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +1 -0
  116. package/Tests/Utils/Dashboard/Components/DashboardMonitorListComponent.test.ts +11 -1
  117. package/Tests/Utils/Dashboard/LabelVariable.test.ts +257 -0
  118. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +144 -14
  119. package/Tests/Utils/MetricUnitUtil.test.ts +81 -0
  120. package/Tests/Utils/Monitor/DeviceMacLearningUtil.test.ts +497 -0
  121. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +546 -0
  122. package/Tests/Utils/Monitor/MonitorTemplateCustomFieldUtil.test.ts +218 -0
  123. package/Tests/Utils/Monitor/NetworkTopologyEndpointAdoption.test.ts +1755 -0
  124. package/Tests/Utils/NetworkAutomation/AutoImportRunChain.test.ts +320 -0
  125. package/Types/BaseDatabase/IncludesAnyOfGroups.ts +77 -0
  126. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  127. package/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.ts +1 -0
  128. package/Types/Dashboard/DashboardVariable.ts +8 -0
  129. package/Types/JSON.ts +4 -0
  130. package/Types/JSONFunctions.ts +16 -0
  131. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  132. package/Types/NetworkAutomation/RuleRunResult.ts +402 -13
  133. package/Types/SerializableObjectDictionary.ts +4 -0
  134. package/Types/Telemetry/InventoryLiveness.ts +56 -0
  135. package/UI/Components/CSVFileUpload/CSVFileUpload.tsx +9 -2
  136. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +46 -21
  137. package/UI/Components/Dashboard/DashboardVariableControl.tsx +285 -0
  138. package/UI/esbuild-config.js +6 -0
  139. package/Utils/Dashboard/Components/DashboardMonitorListComponent.ts +12 -1
  140. package/Utils/Dashboard/LabelVariable.ts +152 -0
  141. package/Utils/Dashboard/VariableUrlState.ts +27 -5
  142. package/Utils/MetricUnitUtil.ts +34 -0
  143. package/Utils/Monitor/DeviceMacLearningUtil.ts +177 -0
  144. package/Utils/Monitor/MetricValueFormatter.ts +342 -0
  145. package/Utils/Monitor/MonitorTemplateCustomFieldUtil.ts +124 -0
  146. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +7 -2
  147. package/Utils/Monitor/NetworkTopologyUtil.ts +693 -0
  148. package/Utils/NetworkAutomation/AutoImportRunChain.ts +138 -0
  149. package/build/dist/Models/DatabaseModels/GlobalConfig.js +12 -12
  150. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/InventoryItem.js +34 -1
  152. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/NetworkDevice.js +128 -0
  154. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  155. package/build/dist/Server/EnvironmentConfig.js +44 -12
  156. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  157. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js +14 -0
  158. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js.map +1 -0
  159. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  160. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  161. package/build/dist/Server/Infrastructure/Redis.js +8 -8
  162. package/build/dist/Server/Infrastructure/Redis.js.map +1 -1
  163. package/build/dist/Server/Infrastructure/Status.js +10 -4
  164. package/build/dist/Server/Infrastructure/Status.js.map +1 -1
  165. package/build/dist/Server/Services/DatabaseService.js +58 -13
  166. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  167. package/build/dist/Server/Services/InventoryItemService.js +28 -1
  168. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  169. package/build/dist/Server/Services/MonitorTemplateService.js +116 -17
  170. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  171. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +270 -37
  172. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  173. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js +65 -0
  174. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js.map +1 -1
  175. package/build/dist/Server/Services/NetworkDeviceService.js +54 -0
  176. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  177. package/build/dist/Server/Services/UserNotificationRuleService.js +60 -25
  178. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  179. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js +29 -4
  180. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js.map +1 -1
  181. package/build/dist/Server/Types/Database/QueryUtil.js +36 -0
  182. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  183. package/build/dist/Server/Types/Markdown.js +75 -0
  184. package/build/dist/Server/Types/Markdown.js.map +1 -1
  185. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +53 -18
  186. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  187. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js +10 -1
  188. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js.map +1 -1
  189. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js +2 -2
  190. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js.map +1 -1
  191. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +58 -35
  192. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  193. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +29 -5
  194. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  195. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +166 -35
  196. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  197. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js +58 -9
  198. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js.map +1 -1
  199. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js +9 -3
  200. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js.map +1 -1
  201. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js +26 -12
  202. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js.map +1 -1
  203. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +74 -33
  204. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  205. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js +114 -0
  206. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js.map +1 -0
  207. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +25 -0
  208. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  209. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +9 -1
  210. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  211. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +70 -13
  212. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  213. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js +54 -0
  214. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js.map +1 -0
  215. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  216. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  217. package/build/dist/Types/Dashboard/DashboardVariable.js +1 -0
  218. package/build/dist/Types/Dashboard/DashboardVariable.js.map +1 -1
  219. package/build/dist/Types/JSON.js +1 -0
  220. package/build/dist/Types/JSON.js.map +1 -1
  221. package/build/dist/Types/JSONFunctions.js +13 -0
  222. package/build/dist/Types/JSONFunctions.js.map +1 -1
  223. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +291 -6
  224. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
  225. package/build/dist/Types/SerializableObjectDictionary.js +4 -0
  226. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  227. package/build/dist/Types/Telemetry/InventoryLiveness.js +37 -0
  228. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -0
  229. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js +7 -0
  230. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js.map +1 -1
  231. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +9 -2
  232. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  233. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js +137 -0
  234. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js.map +1 -0
  235. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js +9 -1
  236. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js.map +1 -1
  237. package/build/dist/Utils/Dashboard/LabelVariable.js +96 -0
  238. package/build/dist/Utils/Dashboard/LabelVariable.js.map +1 -0
  239. package/build/dist/Utils/Dashboard/VariableUrlState.js +19 -4
  240. package/build/dist/Utils/Dashboard/VariableUrlState.js.map +1 -1
  241. package/build/dist/Utils/MetricUnitUtil.js +26 -0
  242. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  243. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js +112 -0
  244. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js.map +1 -0
  245. package/build/dist/Utils/Monitor/MetricValueFormatter.js +276 -0
  246. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -0
  247. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js +101 -0
  248. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js.map +1 -0
  249. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +7 -2
  250. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  251. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +484 -0
  252. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  253. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js +59 -0
  254. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js.map +1 -0
  255. package/jest.config.json +2 -0
  256. package/package.json +1 -1
  257. package/test-setup.sh +9 -9
  258. package/tsconfig.json +51 -11
  259. package/Tests/App/Dashboard/NotificationEmailPreferences.test.tsx +0 -399
@@ -0,0 +1,573 @@
1
+ import { afterEach, describe, expect, test } from "@jest/globals";
2
+ import NetworkDeviceMacLearningUtil, {
3
+ MacLearningResult,
4
+ } from "../../../../Server/Utils/Monitor/NetworkDeviceMacLearningUtil";
5
+ import NetworkDeviceService from "../../../../Server/Services/NetworkDeviceService";
6
+ import NetworkDevice from "../../../../Models/DatabaseModels/NetworkDevice";
7
+ import { MacLearningIpBinding } from "../../../../Utils/Monitor/DeviceMacLearningUtil";
8
+ import ObjectID from "../../../../Types/ObjectID";
9
+ import LIMIT_MAX from "../../../../Types/Database/LimitMax";
10
+ import { JSONObject } from "../../../../Types/JSON";
11
+ import { FindOperator } from "typeorm";
12
+
13
+ /*
14
+ * NetworkDeviceMacLearningUtil is the thin server half of MAC learning: it
15
+ * reads the devices registered at the addresses a router's ARP table bound,
16
+ * hands them to the pure planner, and applies the plan with the hook-free
17
+ * compare-and-set write. The planner's rules are pinned in
18
+ * Tests/Utils/Monitor/DeviceMacLearningUtil.test.ts; what is pinned HERE is
19
+ * the database contract around it:
20
+ *
21
+ * - the read asks about exactly the bound addresses, chunked so the
22
+ * literal `IN (...)` list never exceeds what the planner can take;
23
+ * - the read is NOT narrowed by site or by an empty MAC column - the
24
+ * planner has to see the second device at an address, or the device that
25
+ * already has a MAC, to refuse correctly;
26
+ * - the write is one hook-free statement per planned device, guarded so it
27
+ * fills the column only while it is still NULL.
28
+ */
29
+
30
+ const PROJECT_ID: string = "1c9d4a7b-0000-4000-8000-000000000011";
31
+ const ROUTER_ID: string = "8f2c1f0e-0000-4000-8000-0000000000aa";
32
+ const REGISTER_ID: string = "8f2c1f0e-0000-4000-8000-0000000000bb";
33
+ const HANDSET_ID: string = "8f2c1f0e-0000-4000-8000-0000000000cc";
34
+ const SITE_A: ObjectID = new ObjectID("5a5a5a5a-0000-4000-8000-00000000000a");
35
+ const SITE_B: ObjectID = new ObjectID("5b5b5b5b-0000-4000-8000-00000000000b");
36
+
37
+ const REGISTER_IP: string = "10.0.0.5";
38
+ const REGISTER_MAC: string = "aa:bb:cc:dd:ee:01";
39
+ const HANDSET_IP: string = "10.0.0.6";
40
+ const HANDSET_MAC: string = "aa:bb:cc:dd:ee:02";
41
+
42
+ /*
43
+ * Must track HOSTNAME_LOOKUP_CHUNK_SIZE in NetworkDeviceMacLearningUtil.
44
+ * Written out rather than imported (it is module-private) so that changing
45
+ * the util's constant fails HERE.
46
+ */
47
+ const CHUNK_SIZE: number = 500;
48
+
49
+ let findBySpy: jest.SpyInstance;
50
+ let hookFreeUpdateSpy: jest.SpyInstance;
51
+
52
+ function deviceRow(
53
+ id: string,
54
+ hostname: string,
55
+ overrides?: {
56
+ siteId?: ObjectID;
57
+ macAddress?: string;
58
+ isMacAddressLearned?: boolean;
59
+ },
60
+ ): NetworkDevice {
61
+ const row: NetworkDevice = new NetworkDevice();
62
+ row.id = new ObjectID(id);
63
+ row.hostname = hostname;
64
+ if (overrides?.siteId) {
65
+ row.siteId = overrides.siteId;
66
+ }
67
+ if (overrides?.macAddress !== undefined) {
68
+ row.macAddress = overrides.macAddress;
69
+ }
70
+ if (overrides?.isMacAddressLearned !== undefined) {
71
+ row.isMacAddressLearned = overrides.isMacAddressLearned;
72
+ }
73
+ return row;
74
+ }
75
+
76
+ function binding(ipAddress: string, macAddress: string): MacLearningIpBinding {
77
+ return { ipAddress, macAddress };
78
+ }
79
+
80
+ /*
81
+ * `count` distinct, well-formed addresses. Kept under 40 x 256 so every
82
+ * generated address is unique — the chunk-arithmetic case counts on it.
83
+ */
84
+ function addresses(count: number): Array<string> {
85
+ const list: Array<string> = [];
86
+
87
+ for (let index: number = 0; index < count; index++) {
88
+ list.push(`10.99.${Math.floor(index / 256)}.${index % 256}`);
89
+ }
90
+
91
+ return list;
92
+ }
93
+
94
+ function findByArgs(callIndex: number): JSONObject {
95
+ return findBySpy.mock.calls[callIndex]![0] as JSONObject;
96
+ }
97
+
98
+ function queryOf(callIndex: number): JSONObject {
99
+ return findByArgs(callIndex)["query"] as JSONObject;
100
+ }
101
+
102
+ /*
103
+ * The addresses one read actually asked about. QueryHelper.any renders the
104
+ * chunk into a TypeORM Raw operator — `(alias IN (:...rid))` with a RANDOM
105
+ * parameter name — so the list is recovered from the operator's parameters
106
+ * rather than from a key a test could hard-code.
107
+ */
108
+ function hostnamesIn(query: JSONObject): Array<string> {
109
+ const operator: FindOperator<string> = query[
110
+ "hostname"
111
+ ] as unknown as FindOperator<string>;
112
+
113
+ const parameters: Record<string, unknown> =
114
+ (operator.objectLiteralParameters || {}) as Record<string, unknown>;
115
+
116
+ const values: Array<unknown> =
117
+ (Object.values(parameters)[0] as Array<unknown>) || [];
118
+
119
+ return values.map((value: unknown): string => {
120
+ return String(value);
121
+ });
122
+ }
123
+
124
+ function askedAboutInCall(callIndex: number): Array<string> {
125
+ return hostnamesIn(queryOf(callIndex));
126
+ }
127
+
128
+ function chunkSizes(): Array<number> {
129
+ const sizes: Array<number> = [];
130
+
131
+ for (let index: number = 0; index < findBySpy.mock.calls.length; index++) {
132
+ sizes.push(askedAboutInCall(index).length);
133
+ }
134
+
135
+ return sizes;
136
+ }
137
+
138
+ /*
139
+ * findBy answers each chunk with the seeded rows whose hostname that chunk
140
+ * asked about — the way a real read would — so a candidate can only be
141
+ * planned if some chunk actually asked about its address.
142
+ */
143
+ function mockServices(rows: Array<NetworkDevice> = []): void {
144
+ findBySpy = jest
145
+ .spyOn(NetworkDeviceService, "findBy")
146
+ .mockImplementation(
147
+ async (findBy: unknown): Promise<Array<NetworkDevice>> => {
148
+ const asked: Set<string> = new Set(
149
+ hostnamesIn((findBy as JSONObject)["query"] as JSONObject),
150
+ );
151
+ return rows.filter((row: NetworkDevice) => {
152
+ return asked.has(row.hostname || "");
153
+ });
154
+ },
155
+ );
156
+ hookFreeUpdateSpy = jest
157
+ .spyOn(NetworkDeviceService, "updateColumnsByIdWithoutHooks")
158
+ .mockResolvedValue(undefined);
159
+ }
160
+
161
+ async function learn(
162
+ ipBindings: Array<MacLearningIpBinding>,
163
+ options?: { observingSiteId?: ObjectID | undefined },
164
+ ): Promise<MacLearningResult> {
165
+ return NetworkDeviceMacLearningUtil.learnFromArpBindings({
166
+ projectId: new ObjectID(PROJECT_ID),
167
+ observingDeviceId: new ObjectID(ROUTER_ID),
168
+ observingSiteId: options?.observingSiteId,
169
+ ipBindings: ipBindings,
170
+ });
171
+ }
172
+
173
+ function writtenDeviceIds(): Array<string> {
174
+ return hookFreeUpdateSpy.mock.calls.map((call: Array<unknown>) => {
175
+ return ((call[0] as JSONObject)["id"] as ObjectID).toString();
176
+ });
177
+ }
178
+
179
+ afterEach(() => {
180
+ jest.restoreAllMocks();
181
+ });
182
+
183
+ describe("NetworkDeviceMacLearningUtil.learnFromArpBindings — the candidate read", () => {
184
+ test("asks for the project's live devices at the bound addresses, and nothing narrower", async () => {
185
+ mockServices();
186
+
187
+ await learn([
188
+ binding(REGISTER_IP, REGISTER_MAC),
189
+ binding(HANDSET_IP, HANDSET_MAC),
190
+ ]);
191
+
192
+ expect(findBySpy).toHaveBeenCalledTimes(1);
193
+
194
+ const query: JSONObject = queryOf(0);
195
+
196
+ expect((query["projectId"] as ObjectID).toString()).toBe(PROJECT_ID);
197
+ expect(query["isArchived"]).toBe(false);
198
+ expect(askedAboutInCall(0)).toEqual([REGISTER_IP, HANDSET_IP]);
199
+ /*
200
+ * Deliberately NOT filtered by site or by `macAddress: null`: the
201
+ * planner refuses on what a narrower query would have hidden.
202
+ */
203
+ expect(Object.keys(query).sort()).toEqual([
204
+ "hostname",
205
+ "isArchived",
206
+ "projectId",
207
+ ]);
208
+ });
209
+
210
+ test("selects exactly the columns the planner keys on, as root", async () => {
211
+ mockServices();
212
+
213
+ await learn([binding(REGISTER_IP, REGISTER_MAC)]);
214
+
215
+ const args: JSONObject = findByArgs(0);
216
+
217
+ expect(args["select"]).toEqual({
218
+ _id: true,
219
+ hostname: true,
220
+ siteId: true,
221
+ macAddress: true,
222
+ isMacAddressLearned: true,
223
+ });
224
+ expect(args["props"]).toEqual({ isRoot: true });
225
+ expect(args["limit"]).toBe(LIMIT_MAX);
226
+ expect(args["skip"]).toBe(0);
227
+ });
228
+
229
+ test("one address bound to two MACs is asked about once", async () => {
230
+ mockServices();
231
+
232
+ await learn([
233
+ binding(REGISTER_IP, REGISTER_MAC),
234
+ binding(REGISTER_IP, "aa:bb:cc:dd:ee:99"),
235
+ ]);
236
+
237
+ expect(askedAboutInCall(0)).toEqual([REGISTER_IP]);
238
+ });
239
+
240
+ test("bound addresses are trimmed, and blank ones dropped, before the read", async () => {
241
+ mockServices();
242
+
243
+ await learn([
244
+ binding(` ${REGISTER_IP} `, REGISTER_MAC),
245
+ binding(" ", HANDSET_MAC),
246
+ ]);
247
+
248
+ expect(askedAboutInCall(0)).toEqual([REGISTER_IP]);
249
+ });
250
+
251
+ test("empty bindings issue no read and no write", async () => {
252
+ mockServices();
253
+
254
+ const result: MacLearningResult = await learn([]);
255
+
256
+ expect(findBySpy).not.toHaveBeenCalled();
257
+ expect(hookFreeUpdateSpy).not.toHaveBeenCalled();
258
+ expect(result).toEqual({ candidateDeviceCount: 0, plannedCount: 0 });
259
+ });
260
+
261
+ test("bindings with only blank addresses issue no read either", async () => {
262
+ mockServices();
263
+
264
+ const result: MacLearningResult = await learn([binding("", REGISTER_MAC)]);
265
+
266
+ expect(findBySpy).not.toHaveBeenCalled();
267
+ expect(result).toEqual({ candidateDeviceCount: 0, plannedCount: 0 });
268
+ });
269
+
270
+ test("1,001 distinct addresses are read in chunks of 500, and candidates from every chunk are planned", async () => {
271
+ const bound: Array<string> = addresses(CHUNK_SIZE * 2 + 1);
272
+ const firstInFirstChunk: string = bound[0]!;
273
+ const onlyOneInLastChunk: string = bound[bound.length - 1]!;
274
+
275
+ mockServices([
276
+ deviceRow(REGISTER_ID, firstInFirstChunk, { siteId: SITE_A }),
277
+ deviceRow(HANDSET_ID, onlyOneInLastChunk, { siteId: SITE_A }),
278
+ ]);
279
+
280
+ const result: MacLearningResult = await learn(
281
+ bound.map((ip: string, index: number) => {
282
+ return binding(
283
+ ip,
284
+ `aa:bb:${Math.floor(index / 256)
285
+ .toString(16)
286
+ .padStart(
287
+ 2,
288
+ "0",
289
+ )}:${(index % 256).toString(16).padStart(2, "0")}:00:01`,
290
+ );
291
+ }),
292
+ { observingSiteId: SITE_A },
293
+ );
294
+
295
+ expect(findBySpy).toHaveBeenCalledTimes(3);
296
+ expect(chunkSizes()).toEqual([CHUNK_SIZE, CHUNK_SIZE, 1]);
297
+
298
+ // Every address, once, across the three reads.
299
+ const asked: Array<string> = [];
300
+ for (let index: number = 0; index < findBySpy.mock.calls.length; index++) {
301
+ asked.push(...askedAboutInCall(index));
302
+ }
303
+ expect(asked).toEqual(bound);
304
+
305
+ // A device answered by the first chunk and one by the last both land.
306
+ expect(result).toEqual({ candidateDeviceCount: 2, plannedCount: 2 });
307
+ expect(writtenDeviceIds().sort()).toEqual([REGISTER_ID, HANDSET_ID].sort());
308
+ });
309
+
310
+ test("a returned row with no id is not a candidate", async () => {
311
+ const nameless: NetworkDevice = new NetworkDevice();
312
+ nameless.hostname = REGISTER_IP;
313
+ nameless.siteId = SITE_A;
314
+ mockServices([nameless]);
315
+
316
+ const result: MacLearningResult = await learn(
317
+ [binding(REGISTER_IP, REGISTER_MAC)],
318
+ { observingSiteId: SITE_A },
319
+ );
320
+
321
+ expect(result).toEqual({ candidateDeviceCount: 0, plannedCount: 0 });
322
+ expect(hookFreeUpdateSpy).not.toHaveBeenCalled();
323
+ });
324
+ });
325
+
326
+ describe("NetworkDeviceMacLearningUtil.learnFromArpBindings — the write", () => {
327
+ test("each planned device gets exactly one hook-free compare-and-set write", async () => {
328
+ mockServices([
329
+ deviceRow(REGISTER_ID, REGISTER_IP, { siteId: SITE_A }),
330
+ deviceRow(HANDSET_ID, HANDSET_IP, { siteId: SITE_A }),
331
+ ]);
332
+
333
+ const result: MacLearningResult = await learn(
334
+ [
335
+ binding(REGISTER_IP, "AA-BB-CC-DD-EE-01"),
336
+ binding(HANDSET_IP, HANDSET_MAC),
337
+ ],
338
+ { observingSiteId: SITE_A },
339
+ );
340
+
341
+ expect(hookFreeUpdateSpy).toHaveBeenCalledTimes(2);
342
+
343
+ const byId: Map<string, JSONObject> = new Map(
344
+ hookFreeUpdateSpy.mock.calls.map((call: Array<unknown>) => {
345
+ const input: JSONObject = call[0] as JSONObject;
346
+ return [(input["id"] as ObjectID).toString(), input];
347
+ }),
348
+ );
349
+
350
+ expect(Array.from(byId.keys()).sort()).toEqual(
351
+ [REGISTER_ID, HANDSET_ID].sort(),
352
+ );
353
+
354
+ const registerWrite: JSONObject = byId.get(REGISTER_ID)!;
355
+ // Normalized on the way in: the row never sees the ARP table's spelling.
356
+ expect(registerWrite["data"]).toEqual({
357
+ macAddress: REGISTER_MAC,
358
+ isMacAddressLearned: true,
359
+ });
360
+ /*
361
+ * Fill only while still empty, and never a row deleted between the
362
+ * read and this write. This guard is what makes the whole thing safe to
363
+ * run from every router's walk without a lock.
364
+ */
365
+ expect(registerWrite["expectedData"]).toEqual({
366
+ macAddress: null,
367
+ deletedAt: null,
368
+ });
369
+
370
+ const handsetWrite: JSONObject = byId.get(HANDSET_ID)!;
371
+ expect(handsetWrite["data"]).toEqual({
372
+ macAddress: HANDSET_MAC,
373
+ isMacAddressLearned: true,
374
+ });
375
+ expect(handsetWrite["expectedData"]).toEqual({
376
+ macAddress: null,
377
+ deletedAt: null,
378
+ });
379
+
380
+ expect(result).toEqual({ candidateDeviceCount: 2, plannedCount: 2 });
381
+ });
382
+
383
+ test("the write goes through updateColumnsByIdWithoutHooks and never the hooked update", async () => {
384
+ const hookedUpdateSpy: jest.SpyInstance = jest
385
+ .spyOn(NetworkDeviceService, "updateOneById")
386
+ .mockResolvedValue(1);
387
+ mockServices([deviceRow(REGISTER_ID, REGISTER_IP, { siteId: SITE_A })]);
388
+
389
+ await learn([binding(REGISTER_IP, REGISTER_MAC)], {
390
+ observingSiteId: SITE_A,
391
+ });
392
+
393
+ expect(hookFreeUpdateSpy).toHaveBeenCalledTimes(1);
394
+ expect(hookedUpdateSpy).not.toHaveBeenCalled();
395
+ });
396
+
397
+ test("a device already carrying a MAC is a candidate but is never written", async () => {
398
+ mockServices([
399
+ deviceRow(REGISTER_ID, REGISTER_IP, {
400
+ siteId: SITE_A,
401
+ macAddress: "00:11:22:33:44:55",
402
+ }),
403
+ ]);
404
+
405
+ const result: MacLearningResult = await learn(
406
+ [binding(REGISTER_IP, REGISTER_MAC)],
407
+ { observingSiteId: SITE_A },
408
+ );
409
+
410
+ expect(hookFreeUpdateSpy).not.toHaveBeenCalled();
411
+ expect(result).toEqual({ candidateDeviceCount: 1, plannedCount: 0 });
412
+ });
413
+
414
+ test("a device in another site is a candidate but is never written", async () => {
415
+ mockServices([deviceRow(REGISTER_ID, REGISTER_IP, { siteId: SITE_B })]);
416
+
417
+ const result: MacLearningResult = await learn(
418
+ [binding(REGISTER_IP, REGISTER_MAC)],
419
+ { observingSiteId: SITE_A },
420
+ );
421
+
422
+ expect(hookFreeUpdateSpy).not.toHaveBeenCalled();
423
+ expect(result).toEqual({ candidateDeviceCount: 1, plannedCount: 0 });
424
+ });
425
+
426
+ test("the observing site is compared as a string, so an equal ObjectID matches", async () => {
427
+ // A fresh ObjectID with the same value: equal by string, not by reference.
428
+ mockServices([
429
+ deviceRow(REGISTER_ID, REGISTER_IP, {
430
+ siteId: new ObjectID(SITE_A.toString()),
431
+ }),
432
+ ]);
433
+
434
+ const result: MacLearningResult = await learn(
435
+ [binding(REGISTER_IP, REGISTER_MAC)],
436
+ { observingSiteId: new ObjectID(SITE_A.toString()) },
437
+ );
438
+
439
+ expect(result).toEqual({ candidateDeviceCount: 1, plannedCount: 1 });
440
+ expect(writtenDeviceIds()).toEqual([REGISTER_ID]);
441
+ });
442
+
443
+ test("a router with no site stamps only devices with no site", async () => {
444
+ mockServices([
445
+ deviceRow(REGISTER_ID, REGISTER_IP),
446
+ deviceRow(HANDSET_ID, HANDSET_IP, { siteId: SITE_A }),
447
+ ]);
448
+
449
+ const result: MacLearningResult = await learn(
450
+ [binding(REGISTER_IP, REGISTER_MAC), binding(HANDSET_IP, HANDSET_MAC)],
451
+ { observingSiteId: undefined },
452
+ );
453
+
454
+ expect(writtenDeviceIds()).toEqual([REGISTER_ID]);
455
+ expect(result).toEqual({ candidateDeviceCount: 2, plannedCount: 1 });
456
+ });
457
+
458
+ test("two devices at one address: neither is written, but both are counted as candidates", async () => {
459
+ mockServices([
460
+ deviceRow(REGISTER_ID, REGISTER_IP, { siteId: SITE_A }),
461
+ deviceRow(HANDSET_ID, REGISTER_IP, { siteId: SITE_A }),
462
+ ]);
463
+
464
+ const result: MacLearningResult = await learn(
465
+ [binding(REGISTER_IP, REGISTER_MAC)],
466
+ { observingSiteId: SITE_A },
467
+ );
468
+
469
+ expect(hookFreeUpdateSpy).not.toHaveBeenCalled();
470
+ expect(result).toEqual({ candidateDeviceCount: 2, plannedCount: 0 });
471
+ });
472
+
473
+ test("nothing is written when no device is registered at any bound address", async () => {
474
+ mockServices([deviceRow(REGISTER_ID, "10.0.0.250", { siteId: SITE_A })]);
475
+
476
+ const result: MacLearningResult = await learn(
477
+ [binding(REGISTER_IP, REGISTER_MAC)],
478
+ { observingSiteId: SITE_A },
479
+ );
480
+
481
+ expect(findBySpy).toHaveBeenCalledTimes(1);
482
+ expect(hookFreeUpdateSpy).not.toHaveBeenCalled();
483
+ expect(result).toEqual({ candidateDeviceCount: 0, plannedCount: 0 });
484
+ });
485
+
486
+ test("a write that throws surfaces to the caller, after the writes before it landed", async () => {
487
+ /*
488
+ * The util does not swallow: the walk pipeline wraps this call in its
489
+ * own try/catch (pinned in NetworkInventoryUtil.test.ts), and a silent
490
+ * catch here would hide a broken write path from that log line.
491
+ */
492
+ mockServices([
493
+ deviceRow(REGISTER_ID, REGISTER_IP, { siteId: SITE_A }),
494
+ deviceRow(HANDSET_ID, HANDSET_IP, { siteId: SITE_A }),
495
+ ]);
496
+ hookFreeUpdateSpy
497
+ .mockResolvedValueOnce(undefined)
498
+ .mockRejectedValueOnce(new Error("connection reset"));
499
+
500
+ await expect(
501
+ learn(
502
+ [binding(REGISTER_IP, REGISTER_MAC), binding(HANDSET_IP, HANDSET_MAC)],
503
+ { observingSiteId: SITE_A },
504
+ ),
505
+ ).rejects.toThrow("connection reset");
506
+
507
+ expect(hookFreeUpdateSpy).toHaveBeenCalledTimes(2);
508
+ });
509
+ });
510
+
511
+ /*
512
+ * The pass may correct a MAC it learned itself - the ARP table it learned
513
+ * from is exactly the evidence that goes stale - and only that. The write
514
+ * compares-and-sets on the value the plan saw, so a value that changed
515
+ * under the plan (an operator typing one in) is left alone by the row.
516
+ */
517
+ describe("NetworkDeviceMacLearningUtil.learnFromArpBindings — correcting a learned MAC", () => {
518
+ const STALE_MAC: string = "de:ad:be:ef:00:01";
519
+
520
+ test("a learned MAC the table now contradicts is rewritten, compare-and-set on the old value", async () => {
521
+ mockServices([
522
+ deviceRow(REGISTER_ID, REGISTER_IP, {
523
+ macAddress: STALE_MAC,
524
+ isMacAddressLearned: true,
525
+ }),
526
+ ]);
527
+
528
+ const result: MacLearningResult = await learn([
529
+ binding(REGISTER_IP, REGISTER_MAC),
530
+ ]);
531
+
532
+ expect(hookFreeUpdateSpy).toHaveBeenCalledTimes(1);
533
+ const write: JSONObject = hookFreeUpdateSpy.mock.calls[0]![0] as JSONObject;
534
+ expect(write["data"]).toEqual({
535
+ macAddress: REGISTER_MAC,
536
+ isMacAddressLearned: true,
537
+ });
538
+ expect(write["expectedData"]).toEqual({
539
+ macAddress: STALE_MAC,
540
+ deletedAt: null,
541
+ });
542
+ expect(result).toEqual({ candidateDeviceCount: 1, plannedCount: 1 });
543
+ });
544
+
545
+ test("a typed MAC is never rewritten, however much the table disagrees", async () => {
546
+ mockServices([
547
+ deviceRow(REGISTER_ID, REGISTER_IP, {
548
+ macAddress: STALE_MAC,
549
+ isMacAddressLearned: false,
550
+ }),
551
+ ]);
552
+
553
+ const result: MacLearningResult = await learn([
554
+ binding(REGISTER_IP, REGISTER_MAC),
555
+ ]);
556
+
557
+ expect(hookFreeUpdateSpy).not.toHaveBeenCalled();
558
+ expect(result).toEqual({ candidateDeviceCount: 1, plannedCount: 0 });
559
+ });
560
+
561
+ test("a learned MAC the table still agrees with is left alone", async () => {
562
+ mockServices([
563
+ deviceRow(REGISTER_ID, REGISTER_IP, {
564
+ macAddress: REGISTER_MAC,
565
+ isMacAddressLearned: true,
566
+ }),
567
+ ]);
568
+
569
+ await learn([binding(REGISTER_IP, REGISTER_MAC)]);
570
+
571
+ expect(hookFreeUpdateSpy).not.toHaveBeenCalled();
572
+ });
573
+ });