@oneuptime/common 12.0.9 → 12.0.11

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 (262) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Models/DatabaseModels/RunnerJob.ts +17 -2
  4. package/Server/API/LlmProviderAPI.ts +8 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +240 -0
  8. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  9. package/Server/Services/NetworkDeviceService.ts +265 -12
  10. package/Server/Services/RunnerJobService.ts +24 -1
  11. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  12. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  13. package/Server/Utils/LLM/LLMService.ts +108 -19
  14. package/Server/Utils/Monitor/MonitorAlert.ts +22 -115
  15. package/Server/Utils/Monitor/MonitorIncident.ts +7 -337
  16. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +27 -2
  17. package/Server/Utils/Monitor/SeriesResourceLabels.ts +32 -0
  18. package/Server/Utils/Monitor/SeriesResourceLinker.ts +469 -0
  19. package/Server/Utils/SSRFProtection.ts +5 -4
  20. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  21. package/Tests/App/AdminDashboard/AdminUsersBulkAddToProject.test.tsx +955 -0
  22. package/Tests/App/AdminDashboard/BulkAddUsersToProject.test.ts +778 -0
  23. package/Tests/App/AdminDashboard/BulkAddUsersToProjectModal.test.tsx +1051 -0
  24. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  25. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +81 -6
  26. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  27. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  28. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  29. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  30. package/Tests/App/Dashboard/UserElementEmail.test.tsx +447 -0
  31. package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +284 -0
  32. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  33. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  34. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  35. package/Tests/Server/Services/NetworkDeviceLabelRuleRun.test.ts +618 -0
  36. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  37. package/Tests/Server/Services/NetworkSiteAssignmentRuleRun.test.ts +706 -0
  38. package/Tests/Server/Services/RunnerJobEnqueue.test.ts +412 -0
  39. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  40. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  41. package/Tests/Server/Utils/AI/LLMServiceRequestPolicy.test.ts +2 -2
  42. package/Tests/Server/Utils/AI/LLMServiceRetryPolicy.test.ts +450 -0
  43. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +556 -0
  44. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +547 -0
  45. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +30 -0
  46. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +699 -0
  47. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +55 -0
  48. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  49. package/Tests/Types/API/URLMalformedTolerance.test.ts +301 -0
  50. package/Tests/Types/API/URLTelScheme.test.ts +261 -0
  51. package/Tests/Types/API/URLUnsupportedScheme.test.ts +148 -0
  52. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  53. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  54. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  55. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +184 -0
  56. package/Tests/Types/Runbook/RunbookStepType.test.ts +124 -0
  57. package/Tests/Types/Workflow/BaseModelComponents.test.ts +17 -0
  58. package/Tests/UI/Components/Button.test.tsx +279 -0
  59. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  60. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  61. package/Tests/UI/Components/DictionaryValuePlaceholderAndSuggestions.test.tsx +595 -0
  62. package/Tests/UI/Components/Icon.test.tsx +57 -0
  63. package/Tests/UI/Components/List.test.tsx +20 -3
  64. package/Tests/UI/Components/LogsSavedViewsDropdown.test.tsx +304 -0
  65. package/Tests/UI/Components/LogsViewerClearSavedView.test.tsx +179 -0
  66. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  67. package/Tests/UI/Components/Pagination.test.tsx +721 -146
  68. package/Tests/UI/Components/PaginationIntegration.test.tsx +386 -0
  69. package/Tests/UI/Components/PaginationUtil.test.ts +417 -0
  70. package/Tests/UI/Components/TelemetrySavedViewsDropdown.test.tsx +343 -0
  71. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnControl.test.ts +289 -0
  72. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnOperators.test.ts +271 -0
  73. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRow.test.ts +119 -0
  74. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.test.ts +968 -0
  75. package/Tests/UI/Components/Workflow/ModelColumnEditor.render.test.tsx +766 -0
  76. package/Tests/UI/Utils/RunNetworkRule.test.ts +320 -0
  77. package/Tests/Utils/APIRetryPolicy.test.ts +823 -0
  78. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  79. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  80. package/Tests/Utils/NetworkAutomation/RuleRunSummary.test.ts +286 -0
  81. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  82. package/Types/API/Protocol.ts +9 -0
  83. package/Types/API/URL.ts +286 -20
  84. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  85. package/Types/CustomField/CustomFieldType.ts +28 -0
  86. package/Types/Monitor/MonitorType.ts +11 -0
  87. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  88. package/Types/NetworkAutomation/RuleRunResult.ts +121 -0
  89. package/Types/Runbook/RunbookStepType.ts +23 -0
  90. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -0
  91. package/UI/Components/Button/Button.tsx +14 -22
  92. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  93. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  94. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  95. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  96. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  97. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  98. package/UI/Components/Dictionary/Dictionary.tsx +4 -39
  99. package/UI/Components/Icon/Icon.tsx +1 -1
  100. package/UI/Components/LogsViewer/LogsViewer.tsx +5 -1
  101. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +8 -0
  102. package/UI/Components/LogsViewer/components/LogsPagination.tsx +29 -91
  103. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +2 -0
  104. package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +66 -1
  105. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  106. package/UI/Components/Pagination/Pagination.tsx +373 -328
  107. package/UI/Components/Pagination/PaginationUtil.ts +263 -0
  108. package/UI/Components/Pagination/Screenshots/README.md +15 -0
  109. package/UI/Components/Pagination/Screenshots/pagination-before-after.png +0 -0
  110. package/UI/Components/Pagination/Screenshots/pagination-go-to-page-modal.png +0 -0
  111. package/UI/Components/Pagination/Screenshots/pagination-long-list.png +0 -0
  112. package/UI/Components/Pagination/Screenshots/pagination-mobile.png +0 -0
  113. package/UI/Components/Pagination/Screenshots/pagination-telemetry.png +0 -0
  114. package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +66 -1
  115. package/UI/Components/TelemetryViewer/components/TelemetryPagination.tsx +38 -92
  116. package/UI/Components/TextArea/TextArea.tsx +3 -0
  117. package/UI/Components/Workflow/ArgumentsForm.tsx +18 -1
  118. package/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.tsx +177 -0
  119. package/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.tsx +243 -0
  120. package/UI/Components/Workflow/ColumnEditor/ColumnControl.ts +255 -0
  121. package/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.tsx +178 -0
  122. package/UI/Components/Workflow/ColumnEditor/ColumnOperators.ts +206 -0
  123. package/UI/Components/Workflow/ColumnEditor/ColumnRow.ts +157 -0
  124. package/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.ts +517 -0
  125. package/UI/Components/Workflow/ColumnEditor/ColumnValueInput.tsx +482 -0
  126. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +131 -0
  127. package/UI/Components/Workflow/ColumnEditor/ModelRecordForm.tsx +163 -0
  128. package/UI/Components/Workflow/ModelColumnEditor.tsx +284 -100
  129. package/UI/Utils/NetworkAutomation/RunNetworkRule.ts +122 -0
  130. package/Utils/API.ts +243 -36
  131. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  132. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  133. package/Utils/NetworkAutomation/RuleRunSummary.ts +147 -0
  134. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  135. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  136. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  137. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  138. package/build/dist/Models/DatabaseModels/RunnerJob.js +17 -2
  139. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  140. package/build/dist/Server/API/LlmProviderAPI.js +9 -1
  141. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  143. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  144. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  146. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +202 -0
  147. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
  148. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  149. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  150. package/build/dist/Server/Services/NetworkDeviceService.js +223 -12
  151. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  152. package/build/dist/Server/Services/RunnerJobService.js +20 -2
  153. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  154. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  155. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  156. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  157. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  158. package/build/dist/Server/Utils/LLM/LLMService.js +78 -22
  159. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  160. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -98
  161. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  162. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +7 -254
  163. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  164. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +14 -2
  165. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  166. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +27 -0
  167. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  168. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +285 -0
  169. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -0
  170. package/build/dist/Server/Utils/SSRFProtection.js +5 -4
  171. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  172. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  173. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  174. package/build/dist/Types/API/Protocol.js +9 -0
  175. package/build/dist/Types/API/Protocol.js.map +1 -1
  176. package/build/dist/Types/API/URL.js +241 -18
  177. package/build/dist/Types/API/URL.js.map +1 -1
  178. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  179. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  180. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  181. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  182. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  183. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  184. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +39 -0
  185. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -0
  186. package/build/dist/Types/Runbook/RunbookStepType.js +21 -0
  187. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  188. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
  189. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  190. package/build/dist/UI/Components/Button/Button.js +10 -16
  191. package/build/dist/UI/Components/Button/Button.js.map +1 -1
  192. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  193. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  194. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  195. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  196. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  197. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  198. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  199. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  200. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  201. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  202. package/build/dist/UI/Components/Dictionary/Dictionary.js +4 -18
  203. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  204. package/build/dist/UI/Components/Icon/Icon.js +1 -1
  205. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  206. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +3 -3
  207. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  208. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +5 -1
  209. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  210. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +14 -44
  211. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
  212. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
  213. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  214. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +20 -2
  215. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -1
  216. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  217. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  218. package/build/dist/UI/Components/Pagination/Pagination.js +174 -199
  219. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  220. package/build/dist/UI/Components/Pagination/PaginationUtil.js +160 -0
  221. package/build/dist/UI/Components/Pagination/PaginationUtil.js.map +1 -0
  222. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +20 -2
  223. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
  224. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js +23 -47
  225. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js.map +1 -1
  226. package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
  227. package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
  228. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +14 -2
  229. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  230. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js +86 -0
  231. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js.map +1 -0
  232. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js +119 -0
  233. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js.map +1 -0
  234. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js +206 -0
  235. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js.map +1 -0
  236. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js +57 -0
  237. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js.map +1 -0
  238. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js +150 -0
  239. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js.map +1 -0
  240. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js +67 -0
  241. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js.map +1 -0
  242. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js +309 -0
  243. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js.map +1 -0
  244. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js +204 -0
  245. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js.map +1 -0
  246. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +66 -0
  247. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -0
  248. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js +83 -0
  249. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js.map +1 -0
  250. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +187 -68
  251. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
  252. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js +75 -0
  253. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js.map +1 -0
  254. package/build/dist/Utils/API.js +175 -34
  255. package/build/dist/Utils/API.js.map +1 -1
  256. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  257. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  258. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  259. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  260. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js +78 -0
  261. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js.map +1 -0
  262. package/package.json +1 -1
@@ -0,0 +1,706 @@
1
+ import NetworkDeviceService from "../../../Server/Services/NetworkDeviceService";
2
+ import NetworkSiteAssignmentRuleService from "../../../Server/Services/NetworkSiteAssignmentRuleService";
3
+ import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
4
+ import NetworkSiteAssignmentRule from "../../../Models/DatabaseModels/NetworkSiteAssignmentRule";
5
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
6
+ import BadDataException from "../../../Types/Exception/BadDataException";
7
+ import ObjectID from "../../../Types/ObjectID";
8
+ import { SiteAssignmentRuleRunResult } from "../../../Types/NetworkAutomation/RuleRunResult";
9
+ import { describe, expect, it, afterEach } from "@jest/globals";
10
+
11
+ /*
12
+ * Contract under test - "Run now" for a site assignment rule
13
+ * (OneUptime/oneuptime#3191).
14
+ *
15
+ * Rules only fire on device create, on an identity change, or on the next
16
+ * poll of a device with no site, so a rule written after an estate was
17
+ * imported never reaches it. applySiteAssignmentRuleToExistingDevices is the
18
+ * manual path that does, and it keeps two promises of the automatic one:
19
+ *
20
+ * - priority still decides. A device this rule matches but a
21
+ * higher-priority rule also matches is reported, never moved: one rule's
22
+ * button must not do another rule's work.
23
+ * - a device that already belongs to a site is left alone unless the caller
24
+ * explicitly asked to overwrite, because nothing records whether that
25
+ * site was chosen by a rule or by a person.
26
+ *
27
+ * Everything else here is about being honest with the operator: the counters
28
+ * have to add up, one device's failure must not abandon the rest of the
29
+ * fleet, and a run that stopped at the cap has to say so.
30
+ */
31
+
32
+ const PROJECT_ID: ObjectID = new ObjectID(
33
+ "22222222-2222-4222-8222-222222222222",
34
+ );
35
+ const OTHER_PROJECT_ID: ObjectID = new ObjectID(
36
+ "99999999-9999-4999-8999-999999999999",
37
+ );
38
+ const SITE_A_ID: ObjectID = new ObjectID(
39
+ "11111111-1111-4111-8111-111111111111",
40
+ );
41
+ const SITE_B_ID: ObjectID = new ObjectID(
42
+ "33333333-3333-4333-8333-333333333333",
43
+ );
44
+ const RULE_ID: ObjectID = new ObjectID("44444444-4444-4444-8444-444444444444");
45
+ const OTHER_RULE_ID: ObjectID = new ObjectID(
46
+ "55555555-5555-4555-8555-555555555555",
47
+ );
48
+
49
+ function deviceId(index: number): ObjectID {
50
+ /*
51
+ * Deterministic per-index ids so a paging test can assert exactly which
52
+ * devices were touched without hard-coding ten thousand UUIDs.
53
+ */
54
+ const suffix: string = index.toString(16).padStart(12, "0");
55
+ return new ObjectID(`00000000-0000-4000-8000-${suffix}`);
56
+ }
57
+
58
+ function fakeDevice(data: {
59
+ index?: number | undefined;
60
+ id?: ObjectID | undefined;
61
+ siteId?: ObjectID | undefined;
62
+ hostname?: string | undefined;
63
+ sysName?: string | undefined;
64
+ name?: string | undefined;
65
+ }): NetworkDevice {
66
+ const id: ObjectID = data.id || deviceId(data.index ?? 0);
67
+
68
+ return {
69
+ id: id,
70
+ _id: id.toString(),
71
+ projectId: PROJECT_ID,
72
+ siteId: data.siteId,
73
+ hostname: data.hostname,
74
+ sysName: data.sysName,
75
+ name: data.name,
76
+ } as unknown as NetworkDevice;
77
+ }
78
+
79
+ function fakeRule(data: {
80
+ id?: ObjectID | undefined;
81
+ // null means "a rule row with no site", which is a state the run rejects.
82
+ siteId?: ObjectID | null | undefined;
83
+ subnetCidr?: string | undefined;
84
+ hostnamePattern?: string | undefined;
85
+ priority?: number | undefined;
86
+ createdAt?: Date | undefined;
87
+ }): NetworkSiteAssignmentRule {
88
+ const id: ObjectID = data.id || RULE_ID;
89
+
90
+ return {
91
+ id: id,
92
+ _id: id.toString(),
93
+ projectId: PROJECT_ID,
94
+ siteId: data.siteId === undefined ? SITE_A_ID : data.siteId || undefined,
95
+ subnetCidr: data.subnetCidr,
96
+ hostnamePattern: data.hostnamePattern,
97
+ priority: data.priority ?? 0,
98
+ createdAt: data.createdAt,
99
+ } as unknown as NetworkSiteAssignmentRule;
100
+ }
101
+
102
+ function mockRules(rules: Array<NetworkSiteAssignmentRule>): jest.SpyInstance {
103
+ return jest
104
+ .spyOn(NetworkSiteAssignmentRuleService, "findBy")
105
+ .mockResolvedValue(rules);
106
+ }
107
+
108
+ // A findBy that pages over a fixed list, honouring skip and limit.
109
+ function mockDevices(devices: Array<NetworkDevice>): jest.SpyInstance {
110
+ return jest
111
+ .spyOn(NetworkDeviceService, "findBy")
112
+ .mockImplementation(async (data: any) => {
113
+ const skip: number = data.skip || 0;
114
+ const limit: number = data.limit || devices.length;
115
+ return devices.slice(skip, skip + limit) as any;
116
+ });
117
+ }
118
+
119
+ /*
120
+ * The same, but generated on demand: a truncation test needs more devices
121
+ * than it is worth building arrays for.
122
+ */
123
+ function mockGeneratedDevices(totalDevices: number): jest.SpyInstance {
124
+ return jest
125
+ .spyOn(NetworkDeviceService, "findBy")
126
+ .mockImplementation(async (data: any) => {
127
+ const skip: number = data.skip || 0;
128
+ const limit: number = data.limit || 0;
129
+ const page: Array<NetworkDevice> = [];
130
+
131
+ for (
132
+ let index: number = skip;
133
+ index < Math.min(skip + limit, totalDevices);
134
+ index++
135
+ ) {
136
+ page.push(fakeDevice({ index: index, hostname: "10.0.5.9" }));
137
+ }
138
+
139
+ return page as any;
140
+ });
141
+ }
142
+
143
+ function mockUpdate(): jest.SpyInstance {
144
+ return jest
145
+ .spyOn(NetworkDeviceService, "updateOneById")
146
+ .mockResolvedValue(undefined as never);
147
+ }
148
+
149
+ function run(
150
+ overrides: {
151
+ ruleId?: ObjectID | undefined;
152
+ projectId?: ObjectID | undefined;
153
+ reassignDevicesAlreadyInASite?: boolean | undefined;
154
+ } = {},
155
+ ): Promise<SiteAssignmentRuleRunResult> {
156
+ return NetworkDeviceService.applySiteAssignmentRuleToExistingDevices({
157
+ ruleId: overrides.ruleId || RULE_ID,
158
+ projectId: overrides.projectId || PROJECT_ID,
159
+ reassignDevicesAlreadyInASite:
160
+ overrides.reassignDevicesAlreadyInASite ?? false,
161
+ });
162
+ }
163
+
164
+ describe("NetworkDeviceService.applySiteAssignmentRuleToExistingDevices", () => {
165
+ afterEach(() => {
166
+ jest.restoreAllMocks();
167
+ });
168
+
169
+ describe("rule resolution", () => {
170
+ it("rejects a rule id that is not one of the project's rules", async () => {
171
+ mockRules([fakeRule({ subnetCidr: "10.0.0.0/8" })]);
172
+ mockDevices([]);
173
+
174
+ await expect(run({ ruleId: OTHER_RULE_ID })).rejects.toThrow(
175
+ BadDataException,
176
+ );
177
+ });
178
+
179
+ /*
180
+ * The tenant guard: the rules are loaded by project, so a rule id from
181
+ * another project is simply not in the list and the run refuses. Nothing
182
+ * about another tenant's estate is read or written.
183
+ */
184
+ it("refuses to run a rule belonging to another project", async () => {
185
+ const rulesSpy: jest.SpyInstance = mockRules([]);
186
+ const devicesSpy: jest.SpyInstance = mockDevices([
187
+ fakeDevice({ hostname: "10.0.5.9" }),
188
+ ]);
189
+ const updateSpy: jest.SpyInstance = mockUpdate();
190
+
191
+ await expect(run({ projectId: OTHER_PROJECT_ID })).rejects.toThrow(
192
+ "Assignment rule not found.",
193
+ );
194
+
195
+ expect(rulesSpy.mock.calls[0]![0].query.projectId.toString()).toBe(
196
+ OTHER_PROJECT_ID.toString(),
197
+ );
198
+ expect(devicesSpy).not.toHaveBeenCalled();
199
+ expect(updateSpy).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it("rejects a rule that has no site to assign to", async () => {
203
+ mockRules([fakeRule({ siteId: null, subnetCidr: "10.0.0.0/8" })]);
204
+ mockDevices([]);
205
+
206
+ await expect(run()).rejects.toThrow(
207
+ "This assignment rule has no site to assign devices to.",
208
+ );
209
+ });
210
+
211
+ it("scopes both the rule and the device query to the project", async () => {
212
+ const rulesSpy: jest.SpyInstance = mockRules([
213
+ fakeRule({ subnetCidr: "10.0.0.0/8" }),
214
+ ]);
215
+ const devicesSpy: jest.SpyInstance = mockDevices([]);
216
+
217
+ await run();
218
+
219
+ expect(rulesSpy.mock.calls[0]![0].query.projectId.toString()).toBe(
220
+ PROJECT_ID.toString(),
221
+ );
222
+ expect(devicesSpy.mock.calls[0]![0].query.projectId.toString()).toBe(
223
+ PROJECT_ID.toString(),
224
+ );
225
+ });
226
+
227
+ /*
228
+ * Paging over rows the run is itself writing to is only safe under a
229
+ * stable sort. Ids never change when a site is assigned; a createdAt or
230
+ * name sort would let a row shift between pages and be skipped.
231
+ */
232
+ it("pages devices under a stable id sort", async () => {
233
+ mockRules([fakeRule({ subnetCidr: "10.0.0.0/8" })]);
234
+ const devicesSpy: jest.SpyInstance = mockDevices([]);
235
+
236
+ await run();
237
+
238
+ expect(devicesSpy.mock.calls[0]![0].sort).toEqual({
239
+ _id: SortOrder.Ascending,
240
+ });
241
+ });
242
+ });
243
+
244
+ describe("matching", () => {
245
+ it("assigns a matching device that has no site", async () => {
246
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
247
+ mockDevices([fakeDevice({ hostname: "10.0.5.9" })]);
248
+ const updateSpy: jest.SpyInstance = mockUpdate();
249
+
250
+ const result: SiteAssignmentRuleRunResult = await run();
251
+
252
+ expect(result.devicesEvaluated).toBe(1);
253
+ expect(result.devicesMatched).toBe(1);
254
+ expect(result.devicesAssigned).toBe(1);
255
+ expect(updateSpy).toHaveBeenCalledTimes(1);
256
+
257
+ const args: any = updateSpy.mock.calls[0]![0];
258
+ expect(args.id.toString()).toBe(deviceId(0).toString());
259
+ expect(args.data.siteId.toString()).toBe(SITE_A_ID.toString());
260
+ });
261
+
262
+ /*
263
+ * Through updateOneById as root, never a bulk UPDATE: onUpdateSuccess is
264
+ * what refreshes the rollups of the site the device left and the one it
265
+ * joined, and a raw write would leave both stale.
266
+ */
267
+ it("assigns through updateOneById so site rollups are refreshed", async () => {
268
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
269
+ mockDevices([fakeDevice({ hostname: "10.0.5.9" })]);
270
+ const updateSpy: jest.SpyInstance = mockUpdate();
271
+
272
+ await run();
273
+
274
+ expect(updateSpy.mock.calls[0]![0].props).toEqual({ isRoot: true });
275
+ });
276
+
277
+ it("leaves a device no criterion matches alone", async () => {
278
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
279
+ mockDevices([fakeDevice({ hostname: "172.16.0.1" })]);
280
+ const updateSpy: jest.SpyInstance = mockUpdate();
281
+
282
+ const result: SiteAssignmentRuleRunResult = await run();
283
+
284
+ expect(result.devicesEvaluated).toBe(1);
285
+ expect(result.devicesMatched).toBe(0);
286
+ expect(result.devicesAssigned).toBe(0);
287
+ expect(updateSpy).not.toHaveBeenCalled();
288
+ });
289
+
290
+ /*
291
+ * A discovery import stores the responding IP in `hostname` and the SNMP
292
+ * identity in `name` / `sysName`, so a retroactive run has to try a
293
+ * hostname pattern against all three - exactly as the per-device path
294
+ * does, or the button would disagree with the scan.
295
+ */
296
+ it("matches a hostname pattern against the SNMP sysName", async () => {
297
+ mockRules([fakeRule({ hostnamePattern: "unit-1042-*" })]);
298
+ mockDevices([
299
+ fakeDevice({ hostname: "10.0.5.9", sysName: "unit-1042-core" }),
300
+ ]);
301
+ const updateSpy: jest.SpyInstance = mockUpdate();
302
+
303
+ const result: SiteAssignmentRuleRunResult = await run();
304
+
305
+ expect(result.devicesAssigned).toBe(1);
306
+ expect(updateSpy.mock.calls[0]![0].data.siteId.toString()).toBe(
307
+ SITE_A_ID.toString(),
308
+ );
309
+ });
310
+
311
+ it("matches a hostname pattern against the display name", async () => {
312
+ mockRules([fakeRule({ hostnamePattern: "*0664*" })]);
313
+ mockDevices([
314
+ fakeDevice({ hostname: "10.0.5.9", name: "UN0664LANSWI03" }),
315
+ ]);
316
+
317
+ mockUpdate();
318
+
319
+ const result: SiteAssignmentRuleRunResult = await run();
320
+
321
+ expect(result.devicesAssigned).toBe(1);
322
+ });
323
+
324
+ it("requires every populated criterion to match", async () => {
325
+ mockRules([
326
+ fakeRule({ subnetCidr: "10.0.5.0/24", hostnamePattern: "core-*" }),
327
+ ]);
328
+ mockDevices([
329
+ // In the subnet, wrong name.
330
+ fakeDevice({ index: 0, hostname: "10.0.5.9", name: "edge-1" }),
331
+ // Right name, outside the subnet.
332
+ fakeDevice({ index: 1, hostname: "172.16.0.4", name: "core-1" }),
333
+ // Both.
334
+ fakeDevice({ index: 2, hostname: "10.0.5.10", name: "core-2" }),
335
+ ]);
336
+ const updateSpy: jest.SpyInstance = mockUpdate();
337
+
338
+ const result: SiteAssignmentRuleRunResult = await run();
339
+
340
+ expect(result.devicesEvaluated).toBe(3);
341
+ expect(result.devicesMatched).toBe(1);
342
+ expect(updateSpy).toHaveBeenCalledTimes(1);
343
+ expect(updateSpy.mock.calls[0]![0].id.toString()).toBe(
344
+ deviceId(2).toString(),
345
+ );
346
+ });
347
+
348
+ it("never matches a rule with no criteria at all", async () => {
349
+ mockRules([fakeRule({})]);
350
+ mockDevices([
351
+ fakeDevice({ index: 0, hostname: "10.0.5.9" }),
352
+ fakeDevice({ index: 1, name: "anything" }),
353
+ ]);
354
+ const updateSpy: jest.SpyInstance = mockUpdate();
355
+
356
+ const result: SiteAssignmentRuleRunResult = await run();
357
+
358
+ expect(result.devicesEvaluated).toBe(2);
359
+ expect(result.devicesMatched).toBe(0);
360
+ expect(updateSpy).not.toHaveBeenCalled();
361
+ });
362
+ });
363
+
364
+ describe("devices that already belong to a site", () => {
365
+ it("counts a device already in this rule's site without updating it", async () => {
366
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
367
+ mockDevices([fakeDevice({ hostname: "10.0.5.9", siteId: SITE_A_ID })]);
368
+ const updateSpy: jest.SpyInstance = mockUpdate();
369
+
370
+ const result: SiteAssignmentRuleRunResult = await run();
371
+
372
+ expect(result.devicesMatched).toBe(1);
373
+ expect(result.devicesAlreadyInRuleSite).toBe(1);
374
+ expect(result.devicesAssigned).toBe(0);
375
+ expect(updateSpy).not.toHaveBeenCalled();
376
+ });
377
+
378
+ /*
379
+ * The default. Nothing in the schema says whether SITE_B was chosen by a
380
+ * rule or by a person, so a plain run must not overwrite it.
381
+ */
382
+ it("leaves a device in another site alone by default", async () => {
383
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
384
+ mockDevices([fakeDevice({ hostname: "10.0.5.9", siteId: SITE_B_ID })]);
385
+ const updateSpy: jest.SpyInstance = mockUpdate();
386
+
387
+ const result: SiteAssignmentRuleRunResult = await run();
388
+
389
+ expect(result.devicesMatched).toBe(1);
390
+ expect(result.devicesSkippedAlreadyInAnotherSite).toBe(1);
391
+ expect(result.devicesAssigned).toBe(0);
392
+ expect(updateSpy).not.toHaveBeenCalled();
393
+ });
394
+
395
+ it("moves a device out of another site when explicitly asked to", async () => {
396
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
397
+ mockDevices([fakeDevice({ hostname: "10.0.5.9", siteId: SITE_B_ID })]);
398
+ const updateSpy: jest.SpyInstance = mockUpdate();
399
+
400
+ const result: SiteAssignmentRuleRunResult = await run({
401
+ reassignDevicesAlreadyInASite: true,
402
+ });
403
+
404
+ expect(result.devicesAssigned).toBe(1);
405
+ expect(result.devicesSkippedAlreadyInAnotherSite).toBe(0);
406
+ expect(updateSpy.mock.calls[0]![0].data.siteId.toString()).toBe(
407
+ SITE_A_ID.toString(),
408
+ );
409
+ });
410
+
411
+ /*
412
+ * Even with the overwrite flag on, a device already in the rule's own
413
+ * site is a no-op - re-running a rule must not churn writes (each one
414
+ * would recompute a site rollup) for nothing.
415
+ */
416
+ it("still skips a device already in this rule's site when overwriting", async () => {
417
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
418
+ mockDevices([fakeDevice({ hostname: "10.0.5.9", siteId: SITE_A_ID })]);
419
+ const updateSpy: jest.SpyInstance = mockUpdate();
420
+
421
+ const result: SiteAssignmentRuleRunResult = await run({
422
+ reassignDevicesAlreadyInASite: true,
423
+ });
424
+
425
+ expect(result.devicesAlreadyInRuleSite).toBe(1);
426
+ expect(updateSpy).not.toHaveBeenCalled();
427
+ });
428
+ });
429
+
430
+ describe("priority", () => {
431
+ it("leaves a device a higher-priority rule also matches to that rule", async () => {
432
+ mockRules([
433
+ fakeRule({ subnetCidr: "10.0.0.0/8", priority: 1 }),
434
+ fakeRule({
435
+ id: OTHER_RULE_ID,
436
+ siteId: SITE_B_ID,
437
+ subnetCidr: "10.0.5.0/24",
438
+ priority: 10,
439
+ }),
440
+ ]);
441
+ mockDevices([fakeDevice({ hostname: "10.0.5.9" })]);
442
+ const updateSpy: jest.SpyInstance = mockUpdate();
443
+
444
+ const result: SiteAssignmentRuleRunResult = await run();
445
+
446
+ expect(result.devicesMatched).toBe(1);
447
+ expect(result.devicesClaimedByHigherPriorityRule).toBe(1);
448
+ expect(result.devicesAssigned).toBe(0);
449
+ expect(updateSpy).not.toHaveBeenCalled();
450
+ });
451
+
452
+ it("assigns when this rule is the one that outranks the others", async () => {
453
+ mockRules([
454
+ fakeRule({ subnetCidr: "10.0.5.0/24", priority: 10 }),
455
+ fakeRule({
456
+ id: OTHER_RULE_ID,
457
+ siteId: SITE_B_ID,
458
+ subnetCidr: "10.0.0.0/8",
459
+ priority: 1,
460
+ }),
461
+ ]);
462
+ mockDevices([fakeDevice({ hostname: "10.0.5.9" })]);
463
+ const updateSpy: jest.SpyInstance = mockUpdate();
464
+
465
+ const result: SiteAssignmentRuleRunResult = await run();
466
+
467
+ expect(result.devicesAssigned).toBe(1);
468
+ expect(result.devicesClaimedByHigherPriorityRule).toBe(0);
469
+ expect(updateSpy.mock.calls[0]![0].data.siteId.toString()).toBe(
470
+ SITE_A_ID.toString(),
471
+ );
472
+ });
473
+
474
+ // Ties go to the older rule, the same way the automatic path breaks them.
475
+ it("yields to an equally ranked but older rule", async () => {
476
+ mockRules([
477
+ fakeRule({
478
+ subnetCidr: "10.0.5.0/24",
479
+ priority: 5,
480
+ createdAt: new Date("2024-02-01T00:00:00Z"),
481
+ }),
482
+ fakeRule({
483
+ id: OTHER_RULE_ID,
484
+ siteId: SITE_B_ID,
485
+ subnetCidr: "10.0.5.0/24",
486
+ priority: 5,
487
+ createdAt: new Date("2024-01-01T00:00:00Z"),
488
+ }),
489
+ ]);
490
+ mockDevices([fakeDevice({ hostname: "10.0.5.9" })]);
491
+ const updateSpy: jest.SpyInstance = mockUpdate();
492
+
493
+ const result: SiteAssignmentRuleRunResult = await run();
494
+
495
+ expect(result.devicesClaimedByHigherPriorityRule).toBe(1);
496
+ expect(updateSpy).not.toHaveBeenCalled();
497
+ });
498
+
499
+ it("wins an equally ranked tie when it is the older rule", async () => {
500
+ mockRules([
501
+ fakeRule({
502
+ subnetCidr: "10.0.5.0/24",
503
+ priority: 5,
504
+ createdAt: new Date("2024-01-01T00:00:00Z"),
505
+ }),
506
+ fakeRule({
507
+ id: OTHER_RULE_ID,
508
+ siteId: SITE_B_ID,
509
+ subnetCidr: "10.0.5.0/24",
510
+ priority: 5,
511
+ createdAt: new Date("2024-02-01T00:00:00Z"),
512
+ }),
513
+ ]);
514
+ mockDevices([fakeDevice({ hostname: "10.0.5.9" })]);
515
+ const updateSpy: jest.SpyInstance = mockUpdate();
516
+
517
+ const result: SiteAssignmentRuleRunResult = await run();
518
+
519
+ expect(result.devicesAssigned).toBe(1);
520
+ expect(updateSpy.mock.calls[0]![0].data.siteId.toString()).toBe(
521
+ SITE_A_ID.toString(),
522
+ );
523
+ });
524
+
525
+ /*
526
+ * Priority is checked AFTER the already-in-a-site checks, so a device a
527
+ * higher-priority rule wants is still reported under the reason the
528
+ * operator can act on first.
529
+ */
530
+ it("reports a device already in another site before consulting priority", async () => {
531
+ mockRules([
532
+ fakeRule({ subnetCidr: "10.0.0.0/8", priority: 1 }),
533
+ fakeRule({
534
+ id: OTHER_RULE_ID,
535
+ siteId: SITE_B_ID,
536
+ subnetCidr: "10.0.5.0/24",
537
+ priority: 10,
538
+ }),
539
+ ]);
540
+ mockDevices([fakeDevice({ hostname: "10.0.5.9", siteId: SITE_B_ID })]);
541
+
542
+ const result: SiteAssignmentRuleRunResult = await run();
543
+
544
+ expect(result.devicesSkippedAlreadyInAnotherSite).toBe(1);
545
+ expect(result.devicesClaimedByHigherPriorityRule).toBe(0);
546
+ });
547
+ });
548
+
549
+ describe("resilience and reporting", () => {
550
+ it("counts a failed device update and keeps going", async () => {
551
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
552
+ mockDevices([
553
+ fakeDevice({ index: 0, hostname: "10.0.5.9" }),
554
+ fakeDevice({ index: 1, hostname: "10.0.5.10" }),
555
+ fakeDevice({ index: 2, hostname: "10.0.5.11" }),
556
+ ]);
557
+
558
+ const updateSpy: jest.SpyInstance = jest
559
+ .spyOn(NetworkDeviceService, "updateOneById")
560
+ .mockImplementation(async (data: any) => {
561
+ if (data.id.toString() === deviceId(1).toString()) {
562
+ throw new Error("deadlock detected");
563
+ }
564
+ return undefined as never;
565
+ });
566
+
567
+ const result: SiteAssignmentRuleRunResult = await run();
568
+
569
+ expect(updateSpy).toHaveBeenCalledTimes(3);
570
+ expect(result.devicesMatched).toBe(3);
571
+ expect(result.devicesAssigned).toBe(2);
572
+ expect(result.devicesFailed).toBe(1);
573
+ });
574
+
575
+ /*
576
+ * A row with no usable id cannot be updated. It still has to be
577
+ * accounted for, or the buckets would not add up to devicesMatched and
578
+ * the summary would silently under-report.
579
+ */
580
+ it("counts a device with no id as a failure rather than losing it", async () => {
581
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
582
+ mockDevices([
583
+ {
584
+ projectId: PROJECT_ID,
585
+ hostname: "10.0.5.9",
586
+ } as unknown as NetworkDevice,
587
+ ]);
588
+ const updateSpy: jest.SpyInstance = mockUpdate();
589
+
590
+ const result: SiteAssignmentRuleRunResult = await run();
591
+
592
+ expect(result.devicesMatched).toBe(1);
593
+ expect(result.devicesFailed).toBe(1);
594
+ expect(result.devicesAssigned).toBe(0);
595
+ expect(updateSpy).not.toHaveBeenCalled();
596
+ });
597
+
598
+ it("returns an all-zero result for a project with no devices", async () => {
599
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
600
+ mockDevices([]);
601
+ const updateSpy: jest.SpyInstance = mockUpdate();
602
+
603
+ const result: SiteAssignmentRuleRunResult = await run();
604
+
605
+ expect(result).toEqual({
606
+ devicesEvaluated: 0,
607
+ devicesMatched: 0,
608
+ devicesAssigned: 0,
609
+ devicesAlreadyInRuleSite: 0,
610
+ devicesSkippedAlreadyInAnotherSite: 0,
611
+ devicesClaimedByHigherPriorityRule: 0,
612
+ devicesFailed: 0,
613
+ isTruncated: false,
614
+ });
615
+ expect(updateSpy).not.toHaveBeenCalled();
616
+ });
617
+
618
+ /*
619
+ * Every evaluated device lands in exactly one bucket, or a zero
620
+ * assignment count could never be explained to the operator.
621
+ */
622
+ it("accounts for every matched device in exactly one bucket", async () => {
623
+ mockRules([
624
+ fakeRule({ subnetCidr: "10.0.0.0/8", priority: 1 }),
625
+ fakeRule({
626
+ id: OTHER_RULE_ID,
627
+ siteId: SITE_B_ID,
628
+ subnetCidr: "10.0.9.0/24",
629
+ priority: 10,
630
+ }),
631
+ ]);
632
+ mockDevices([
633
+ // Assigned.
634
+ fakeDevice({ index: 0, hostname: "10.0.5.9" }),
635
+ // Already in this rule's site.
636
+ fakeDevice({ index: 1, hostname: "10.0.5.10", siteId: SITE_A_ID }),
637
+ // In another site.
638
+ fakeDevice({ index: 2, hostname: "10.0.5.11", siteId: SITE_B_ID }),
639
+ // Claimed by the higher-priority rule.
640
+ fakeDevice({ index: 3, hostname: "10.0.9.4" }),
641
+ // No match at all.
642
+ fakeDevice({ index: 4, hostname: "172.16.0.1" }),
643
+ ]);
644
+ mockUpdate();
645
+
646
+ const result: SiteAssignmentRuleRunResult = await run();
647
+
648
+ expect(result.devicesEvaluated).toBe(5);
649
+ expect(result.devicesMatched).toBe(4);
650
+ expect(
651
+ result.devicesAssigned +
652
+ result.devicesAlreadyInRuleSite +
653
+ result.devicesSkippedAlreadyInAnotherSite +
654
+ result.devicesClaimedByHigherPriorityRule +
655
+ result.devicesFailed,
656
+ ).toBe(result.devicesMatched);
657
+ });
658
+ });
659
+
660
+ describe("paging and the run cap", () => {
661
+ it("walks every page of a multi-page estate", async () => {
662
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
663
+ const devicesSpy: jest.SpyInstance = mockGeneratedDevices(2500);
664
+ const updateSpy: jest.SpyInstance = mockUpdate();
665
+
666
+ const result: SiteAssignmentRuleRunResult = await run();
667
+
668
+ expect(result.devicesEvaluated).toBe(2500);
669
+ expect(result.devicesAssigned).toBe(2500);
670
+ expect(updateSpy).toHaveBeenCalledTimes(2500);
671
+ expect(result.isTruncated).toBe(false);
672
+
673
+ // 1000 + 1000 + 500, and the short page ends the walk.
674
+ expect(devicesSpy).toHaveBeenCalledTimes(3);
675
+ expect(devicesSpy.mock.calls[1]![0].skip).toBe(1000);
676
+ expect(devicesSpy.mock.calls[2]![0].skip).toBe(2000);
677
+ });
678
+
679
+ it("stops at the cap and reports the run as truncated", async () => {
680
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
681
+ mockGeneratedDevices(10500);
682
+ mockUpdate();
683
+
684
+ const result: SiteAssignmentRuleRunResult = await run();
685
+
686
+ expect(result.devicesEvaluated).toBe(10000);
687
+ expect(result.isTruncated).toBe(true);
688
+ });
689
+
690
+ /*
691
+ * A full last page is not proof that more devices exist. An estate of
692
+ * exactly the cap must not report a truncation, or the UI would tell the
693
+ * operator to run it again forever.
694
+ */
695
+ it("does not report truncation for an estate of exactly the cap", async () => {
696
+ mockRules([fakeRule({ subnetCidr: "10.0.5.0/24" })]);
697
+ mockGeneratedDevices(10000);
698
+ mockUpdate();
699
+
700
+ const result: SiteAssignmentRuleRunResult = await run();
701
+
702
+ expect(result.devicesEvaluated).toBe(10000);
703
+ expect(result.isTruncated).toBe(false);
704
+ });
705
+ });
706
+ });