@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,1043 @@
1
+ import NetworkDeviceLinkService from "../../../Server/Services/NetworkDeviceLinkService";
2
+ import NetworkDeviceService from "../../../Server/Services/NetworkDeviceService";
3
+ import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
4
+ import NetworkDeviceLink from "../../../Models/DatabaseModels/NetworkDeviceLink";
5
+ import BadDataException from "../../../Types/Exception/BadDataException";
6
+ import ObjectID from "../../../Types/ObjectID";
7
+ import CreateBy from "../../../Server/Types/Database/CreateBy";
8
+ import UpdateBy from "../../../Server/Types/Database/UpdateBy";
9
+ import { OnCreate, OnUpdate } from "../../../Server/Types/Database/Hooks";
10
+ import { describe, expect, it, afterEach } from "@jest/globals";
11
+
12
+ /*
13
+ * Contract under test - the declared-parent invariant on NetworkDeviceLink
14
+ * (OneUptime/oneuptime#3192):
15
+ *
16
+ * a link's parentDeviceId, when set, must name one of the two devices that
17
+ * link connects.
18
+ *
19
+ * A parent that is not an end is not a statement about this link at all. The
20
+ * topology builder ignores it (buildTopology only stamps edge.parentNodeId
21
+ * when parentDeviceId matches an end), so the operator would declare a
22
+ * hierarchy, save it successfully, and watch the map not move. Hence both
23
+ * write paths refuse it up front rather than storing a row that means nothing.
24
+ *
25
+ * onBeforeUpdate carries the harder half of the invariant, because an update
26
+ * can break it from the other side: leave the parent alone and move an END out
27
+ * from under it. So the post-update state is what gets validated, assembled
28
+ * from the payload where it speaks and from the stored row where it does not -
29
+ * for every row the update's query matches, not just one.
30
+ *
31
+ * All of it runs against stubbed reads. No database.
32
+ */
33
+
34
+ const PROJECT_ID: ObjectID = new ObjectID(
35
+ "22222222-2222-4222-8222-222222222222",
36
+ );
37
+ const DEVICE_A_ID: ObjectID = new ObjectID(
38
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
39
+ );
40
+ const DEVICE_B_ID: ObjectID = new ObjectID(
41
+ "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
42
+ );
43
+ // The third wheel: a real device in the project that this link does not touch.
44
+ const DEVICE_C_ID: ObjectID = new ObjectID(
45
+ "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
46
+ );
47
+ const LINK_ID: ObjectID = new ObjectID("11111111-1111-4111-8111-111111111111");
48
+ const OTHER_LINK_ID: ObjectID = new ObjectID(
49
+ "33333333-3333-4333-8333-333333333333",
50
+ );
51
+
52
+ const PARENT_NOT_AN_END_MESSAGE: string =
53
+ "The parent device must be one of the two devices this link connects.";
54
+
55
+ /*
56
+ * onBeforeCreate reads both ends back to prove they exist and are in the
57
+ * project. Every create test needs that read to succeed before the parent
58
+ * check is even reached, so it gets its own helper.
59
+ */
60
+ function mockBothEndsExist(): jest.SpyInstance {
61
+ return jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue([
62
+ {
63
+ _id: DEVICE_A_ID.toString(),
64
+ id: DEVICE_A_ID,
65
+ } as unknown as NetworkDevice,
66
+ {
67
+ _id: DEVICE_B_ID.toString(),
68
+ id: DEVICE_B_ID,
69
+ } as unknown as NetworkDevice,
70
+ ]);
71
+ }
72
+
73
+ function makeCreateBy(
74
+ data: Record<string, unknown>,
75
+ ): CreateBy<NetworkDeviceLink> {
76
+ return {
77
+ data: {
78
+ projectId: PROJECT_ID,
79
+ ...data,
80
+ } as unknown as NetworkDeviceLink,
81
+ props: { isRoot: true },
82
+ };
83
+ }
84
+
85
+ function storedLink(data: {
86
+ _id?: ObjectID;
87
+ fromDeviceId?: ObjectID | undefined;
88
+ toDeviceId?: ObjectID | undefined;
89
+ parentDeviceId?: ObjectID | undefined;
90
+ }): NetworkDeviceLink {
91
+ return {
92
+ _id: (data._id || LINK_ID).toString(),
93
+ id: data._id || LINK_ID,
94
+ /*
95
+ * Carried because moving an end re-checks that the new device belongs
96
+ * to the link's project, exactly as create does — the FK is global, so
97
+ * without that check an update could re-point an end at another
98
+ * project's device and surface its name on this project's map.
99
+ */
100
+ projectId: PROJECT_ID,
101
+ fromDeviceId: data.fromDeviceId,
102
+ toDeviceId: data.toDeviceId,
103
+ parentDeviceId: data.parentDeviceId,
104
+ } as unknown as NetworkDeviceLink;
105
+ }
106
+
107
+ /*
108
+ * The device read that the moved-end tenancy check performs. Returns all
109
+ * three project devices, so any end move is legitimate unless a test
110
+ * deliberately overrides it — which the tenancy tests do.
111
+ */
112
+ function mockProjectDevicesExist(): jest.SpyInstance {
113
+ return jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue(
114
+ [DEVICE_A_ID, DEVICE_B_ID, DEVICE_C_ID].map((id: ObjectID) => {
115
+ return {
116
+ _id: id.toString(),
117
+ id: id,
118
+ } as unknown as NetworkDevice;
119
+ }),
120
+ );
121
+ }
122
+
123
+ function makeUpdateBy(
124
+ data: Record<string, unknown>,
125
+ ): UpdateBy<NetworkDeviceLink> {
126
+ return {
127
+ query: { _id: LINK_ID.toString() },
128
+ data: data,
129
+ props: { isRoot: true },
130
+ } as unknown as UpdateBy<NetworkDeviceLink>;
131
+ }
132
+
133
+ // The stored rows an update is validated against.
134
+ function mockStoredLinks(links: Array<NetworkDeviceLink>): jest.SpyInstance {
135
+ /*
136
+ * Installed alongside, so a test about the PARENT invariant is never
137
+ * incidentally failed by the tenancy check on an end it moved to set
138
+ * the scene. Tests about tenancy itself re-stub this.
139
+ */
140
+ mockProjectDevicesExist();
141
+ return jest
142
+ .spyOn(NetworkDeviceLinkService, "findBy")
143
+ .mockResolvedValue(links);
144
+ }
145
+
146
+ describe("NetworkDeviceLinkService.onBeforeCreate (declared parent)", () => {
147
+ afterEach(() => {
148
+ jest.restoreAllMocks();
149
+ });
150
+
151
+ it("accepts a parent that is the link's from-device", async () => {
152
+ mockBothEndsExist();
153
+
154
+ const result: OnCreate<NetworkDeviceLink> = await (
155
+ NetworkDeviceLinkService as any
156
+ ).onBeforeCreate(
157
+ makeCreateBy({
158
+ fromDeviceId: DEVICE_A_ID,
159
+ toDeviceId: DEVICE_B_ID,
160
+ parentDeviceId: DEVICE_A_ID,
161
+ }),
162
+ );
163
+
164
+ expect(result.carryForward).toBeNull();
165
+ });
166
+
167
+ it("accepts a parent that is the link's to-device", async () => {
168
+ mockBothEndsExist();
169
+
170
+ const result: OnCreate<NetworkDeviceLink> = await (
171
+ NetworkDeviceLinkService as any
172
+ ).onBeforeCreate(
173
+ makeCreateBy({
174
+ fromDeviceId: DEVICE_A_ID,
175
+ toDeviceId: DEVICE_B_ID,
176
+ parentDeviceId: DEVICE_B_ID,
177
+ }),
178
+ );
179
+
180
+ expect(result.carryForward).toBeNull();
181
+ });
182
+
183
+ /*
184
+ * The common case, and the one that must stay cheap: most links are peers
185
+ * (two access switches on the same trunk), and declaring nothing is how an
186
+ * operator says "these are siblings, let the layout decide".
187
+ */
188
+ it("accepts a link with no declared parent at all", async () => {
189
+ mockBothEndsExist();
190
+
191
+ const result: OnCreate<NetworkDeviceLink> = await (
192
+ NetworkDeviceLinkService as any
193
+ ).onBeforeCreate(
194
+ makeCreateBy({
195
+ fromDeviceId: DEVICE_A_ID,
196
+ toDeviceId: DEVICE_B_ID,
197
+ }),
198
+ );
199
+
200
+ expect(result.carryForward).toBeNull();
201
+ });
202
+
203
+ it("rejects a parent naming a third device, and says why", async () => {
204
+ mockBothEndsExist();
205
+
206
+ await expect(
207
+ (NetworkDeviceLinkService as any).onBeforeCreate(
208
+ makeCreateBy({
209
+ fromDeviceId: DEVICE_A_ID,
210
+ toDeviceId: DEVICE_B_ID,
211
+ parentDeviceId: DEVICE_C_ID,
212
+ }),
213
+ ),
214
+ ).rejects.toThrow(BadDataException);
215
+
216
+ /*
217
+ * The message is the whole user experience of this guard - it is what the
218
+ * dashboard renders when the save fails - so it has to name the constraint
219
+ * rather than say "bad data".
220
+ */
221
+ await expect(
222
+ (NetworkDeviceLinkService as any).onBeforeCreate(
223
+ makeCreateBy({
224
+ fromDeviceId: DEVICE_A_ID,
225
+ toDeviceId: DEVICE_B_ID,
226
+ parentDeviceId: DEVICE_C_ID,
227
+ }),
228
+ ),
229
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
230
+ });
231
+
232
+ /*
233
+ * The dashboard's link form posts the RELATION (`{ parentDevice: { _id } }`),
234
+ * never the FK column, because DatabaseBaseModel serialises the entity
235
+ * column it was handed. A guard that read only `parentDeviceId` would wave
236
+ * every UI-authored declaration straight through.
237
+ */
238
+ it("rejects a third-device parent posted as the `parentDevice` relation", async () => {
239
+ mockBothEndsExist();
240
+
241
+ await expect(
242
+ (NetworkDeviceLinkService as any).onBeforeCreate(
243
+ makeCreateBy({
244
+ fromDevice: { _id: DEVICE_A_ID.toString() },
245
+ toDevice: { _id: DEVICE_B_ID.toString() },
246
+ parentDevice: { _id: DEVICE_C_ID.toString() },
247
+ }),
248
+ ),
249
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
250
+ });
251
+
252
+ it("accepts a valid parent posted as the `parentDevice` relation", async () => {
253
+ mockBothEndsExist();
254
+
255
+ const result: OnCreate<NetworkDeviceLink> = await (
256
+ NetworkDeviceLinkService as any
257
+ ).onBeforeCreate(
258
+ makeCreateBy({
259
+ fromDevice: { _id: DEVICE_A_ID.toString() },
260
+ toDevice: { _id: DEVICE_B_ID.toString() },
261
+ parentDevice: { _id: DEVICE_A_ID.toString() },
262
+ }),
263
+ );
264
+
265
+ expect(result.carryForward).toBeNull();
266
+ });
267
+
268
+ /*
269
+ * Determinism: the two spellings are the same statement, so a payload that
270
+ * mixes them - column for the ends, relation for the parent, or the reverse
271
+ * - has to land on the same verdict as either pure form.
272
+ */
273
+ it("reaches the same verdict whichever spelling each reference uses", async () => {
274
+ mockBothEndsExist();
275
+
276
+ const payloads: Array<Record<string, unknown>> = [
277
+ {
278
+ fromDeviceId: DEVICE_A_ID,
279
+ toDeviceId: DEVICE_B_ID,
280
+ parentDevice: { _id: DEVICE_C_ID.toString() },
281
+ },
282
+ {
283
+ fromDevice: { _id: DEVICE_A_ID.toString() },
284
+ toDevice: { _id: DEVICE_B_ID.toString() },
285
+ parentDeviceId: DEVICE_C_ID,
286
+ },
287
+ // Key order reversed: the reader must not depend on insertion order.
288
+ {
289
+ parentDeviceId: DEVICE_C_ID,
290
+ toDeviceId: DEVICE_B_ID,
291
+ fromDeviceId: DEVICE_A_ID,
292
+ },
293
+ ];
294
+
295
+ for (const payload of payloads) {
296
+ await expect(
297
+ (NetworkDeviceLinkService as any).onBeforeCreate(makeCreateBy(payload)),
298
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
299
+ }
300
+ });
301
+
302
+ /*
303
+ * The parent check sits behind the existing guards on purpose: a link with
304
+ * one end missing, or both ends the same device, has no "two ends" for a
305
+ * parent to be one of. Those errors must stay the ones reported.
306
+ */
307
+ it("still reports the self-link error before looking at the parent", async () => {
308
+ mockBothEndsExist();
309
+
310
+ await expect(
311
+ (NetworkDeviceLinkService as any).onBeforeCreate(
312
+ makeCreateBy({
313
+ fromDeviceId: DEVICE_A_ID,
314
+ toDeviceId: DEVICE_A_ID,
315
+ parentDeviceId: DEVICE_C_ID,
316
+ }),
317
+ ),
318
+ ).rejects.toThrow("A device cannot be linked to itself.");
319
+ });
320
+ });
321
+
322
+ describe("NetworkDeviceLinkService.onBeforeUpdate (declared parent)", () => {
323
+ afterEach(() => {
324
+ jest.restoreAllMocks();
325
+ });
326
+
327
+ /*
328
+ * The whole reason the hook computes `touchesHierarchy` first. Link rows are
329
+ * rewritten by every port rename and every monitor rebind; making each of
330
+ * those pay for a LIMIT_MAX read of the matched rows would be a real cost
331
+ * for a guard that could not possibly fire.
332
+ */
333
+ it("performs no read when the payload touches neither an end nor the parent", async () => {
334
+ const findBySpy: jest.SpyInstance = jest.spyOn(
335
+ NetworkDeviceLinkService,
336
+ "findBy",
337
+ );
338
+
339
+ const result: OnUpdate<NetworkDeviceLink> = await (
340
+ NetworkDeviceLinkService as any
341
+ ).onBeforeUpdate(
342
+ makeUpdateBy({
343
+ fromPort: "Gi1/0/24",
344
+ description: "patch panel B",
345
+ }),
346
+ );
347
+
348
+ expect(findBySpy).not.toHaveBeenCalled();
349
+ expect(result.carryForward).toBeNull();
350
+ });
351
+
352
+ it("rejects setting the parent to a device the stored link does not touch", async () => {
353
+ mockStoredLinks([
354
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
355
+ ]);
356
+
357
+ await expect(
358
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
359
+ makeUpdateBy({ parentDeviceId: DEVICE_C_ID }),
360
+ ),
361
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
362
+ });
363
+
364
+ it("accepts setting the parent to one of the stored link's own ends", async () => {
365
+ mockStoredLinks([
366
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
367
+ ]);
368
+
369
+ const result: OnUpdate<NetworkDeviceLink> = await (
370
+ NetworkDeviceLinkService as any
371
+ ).onBeforeUpdate(makeUpdateBy({ parentDeviceId: DEVICE_B_ID }));
372
+
373
+ expect(result.carryForward).toBeNull();
374
+ });
375
+
376
+ it("rejects a third-device parent posted as the `parentDevice` relation", async () => {
377
+ mockStoredLinks([
378
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
379
+ ]);
380
+
381
+ await expect(
382
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
383
+ makeUpdateBy({ parentDevice: { _id: DEVICE_C_ID.toString() } }),
384
+ ),
385
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
386
+ });
387
+
388
+ /*
389
+ * The non-obvious half of the invariant. Nobody touched the parent here -
390
+ * the payload only re-points an end - but A->B with parent A becomes C->B,
391
+ * and A is suddenly a device this link has never heard of. Checking only the
392
+ * written parent would let this through and leave a stored row that the
393
+ * topology builder silently ignores forever.
394
+ */
395
+ it("rejects moving an end out from under an already-declared parent", async () => {
396
+ mockStoredLinks([
397
+ storedLink({
398
+ fromDeviceId: DEVICE_A_ID,
399
+ toDeviceId: DEVICE_B_ID,
400
+ parentDeviceId: DEVICE_A_ID,
401
+ }),
402
+ ]);
403
+
404
+ await expect(
405
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
406
+ makeUpdateBy({ fromDeviceId: DEVICE_C_ID }),
407
+ ),
408
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
409
+ });
410
+
411
+ it("rejects moving an end out from under a parent given as the `fromDevice` relation", async () => {
412
+ mockStoredLinks([
413
+ storedLink({
414
+ fromDeviceId: DEVICE_A_ID,
415
+ toDeviceId: DEVICE_B_ID,
416
+ parentDeviceId: DEVICE_A_ID,
417
+ }),
418
+ ]);
419
+
420
+ await expect(
421
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
422
+ makeUpdateBy({ fromDevice: { _id: DEVICE_C_ID.toString() } }),
423
+ ),
424
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
425
+ });
426
+
427
+ /*
428
+ * The other end may move freely as long as the declared parent survives it:
429
+ * A->B with parent A becomes A->C, and A is still an end. Re-patching a
430
+ * downlink to a different access switch is routine and must not be blocked.
431
+ */
432
+ it("accepts moving the end that is NOT the declared parent", async () => {
433
+ mockStoredLinks([
434
+ storedLink({
435
+ fromDeviceId: DEVICE_A_ID,
436
+ toDeviceId: DEVICE_B_ID,
437
+ parentDeviceId: DEVICE_A_ID,
438
+ }),
439
+ ]);
440
+
441
+ const result: OnUpdate<NetworkDeviceLink> = await (
442
+ NetworkDeviceLinkService as any
443
+ ).onBeforeUpdate(makeUpdateBy({ toDeviceId: DEVICE_C_ID }));
444
+
445
+ expect(result.carryForward).toBeNull();
446
+ });
447
+
448
+ // Moving an end AND re-declaring the parent onto the new end in one write.
449
+ it("accepts moving an end when the payload re-points the parent at the same time", async () => {
450
+ mockStoredLinks([
451
+ storedLink({
452
+ fromDeviceId: DEVICE_A_ID,
453
+ toDeviceId: DEVICE_B_ID,
454
+ parentDeviceId: DEVICE_A_ID,
455
+ }),
456
+ ]);
457
+
458
+ const result: OnUpdate<NetworkDeviceLink> = await (
459
+ NetworkDeviceLinkService as any
460
+ ).onBeforeUpdate(
461
+ makeUpdateBy({
462
+ fromDeviceId: DEVICE_C_ID,
463
+ parentDeviceId: DEVICE_C_ID,
464
+ }),
465
+ );
466
+
467
+ expect(result.carryForward).toBeNull();
468
+ });
469
+
470
+ /*
471
+ * The isParentWritten branch, which is the subtle one. Demoting a hierarchy
472
+ * back to a peer link means writing the parent key with an empty value -
473
+ * and RelationIdUtil reads that as null, exactly like "not written at all".
474
+ * If the hook decided on the VALUE it would fall back to the stored parent
475
+ * A, find it absent from the new C->B ends, and refuse the very write that
476
+ * was removing the offending declaration. The KEY's presence is what
477
+ * decides.
478
+ */
479
+ it("accepts clearing the parent with null even while an end moves away from it", async () => {
480
+ mockStoredLinks([
481
+ storedLink({
482
+ fromDeviceId: DEVICE_A_ID,
483
+ toDeviceId: DEVICE_B_ID,
484
+ parentDeviceId: DEVICE_A_ID,
485
+ }),
486
+ ]);
487
+
488
+ const result: OnUpdate<NetworkDeviceLink> = await (
489
+ NetworkDeviceLinkService as any
490
+ ).onBeforeUpdate(
491
+ makeUpdateBy({
492
+ fromDeviceId: DEVICE_C_ID,
493
+ parentDeviceId: null,
494
+ }),
495
+ );
496
+
497
+ expect(result.carryForward).toBeNull();
498
+ });
499
+
500
+ // Same branch, reached through the other empty values the UI can send.
501
+ it("treats empty-string and a null relation as clearing the parent too", async () => {
502
+ const emptyValues: Array<Record<string, unknown>> = [
503
+ { fromDeviceId: DEVICE_C_ID, parentDeviceId: "" },
504
+ { fromDeviceId: DEVICE_C_ID, parentDevice: null },
505
+ ];
506
+
507
+ for (const data of emptyValues) {
508
+ mockStoredLinks([
509
+ storedLink({
510
+ fromDeviceId: DEVICE_A_ID,
511
+ toDeviceId: DEVICE_B_ID,
512
+ parentDeviceId: DEVICE_A_ID,
513
+ }),
514
+ ]);
515
+
516
+ const result: OnUpdate<NetworkDeviceLink> = await (
517
+ NetworkDeviceLinkService as any
518
+ ).onBeforeUpdate(makeUpdateBy(data));
519
+
520
+ expect(result.carryForward).toBeNull();
521
+ jest.restoreAllMocks();
522
+ }
523
+ });
524
+
525
+ /*
526
+ * undefined is NOT one of them, and the difference is not pedantry.
527
+ * TypeORM drops undefined-valued keys before building the SET list, so
528
+ * such a key writes nothing at all. Reading it as "the parent is being
529
+ * cleared" makes this hook validate a row that will never exist: it
530
+ * would wave the write through, and the stored parent would survive on
531
+ * a link whose end had just moved out from under it — precisely the
532
+ * state the hook exists to prevent.
533
+ */
534
+ it("refuses a moved end when a present-but-undefined parent key would leave the stored parent stranded", async () => {
535
+ mockStoredLinks([
536
+ storedLink({
537
+ fromDeviceId: DEVICE_A_ID,
538
+ toDeviceId: DEVICE_B_ID,
539
+ parentDeviceId: DEVICE_A_ID,
540
+ }),
541
+ ]);
542
+
543
+ await expect(
544
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
545
+ makeUpdateBy({
546
+ fromDeviceId: DEVICE_C_ID,
547
+ parentDeviceId: undefined,
548
+ }),
549
+ ),
550
+ ).rejects.toThrow(
551
+ "The parent device must be one of the two devices this link connects.",
552
+ );
553
+ });
554
+
555
+ // And a payload of nothing BUT undefined writes nothing, so it reads nothing.
556
+ it("takes the early return when every hierarchy key present holds undefined", async () => {
557
+ const findBySpy: jest.SpyInstance = mockStoredLinks([]);
558
+
559
+ const result: OnUpdate<NetworkDeviceLink> = await (
560
+ NetworkDeviceLinkService as any
561
+ ).onBeforeUpdate(
562
+ makeUpdateBy({
563
+ fromDeviceId: undefined,
564
+ parentDeviceId: undefined,
565
+ }),
566
+ );
567
+
568
+ expect(result.carryForward).toBeNull();
569
+ expect(findBySpy).not.toHaveBeenCalled();
570
+ });
571
+
572
+ /*
573
+ * Clearing the parent still counts as touching the hierarchy, so it must not
574
+ * take the early return - the same write can be moving an end, and that end
575
+ * move is what the read exists to check.
576
+ */
577
+ it("still reads the stored rows when the only hierarchy key present clears the parent", async () => {
578
+ const findBySpy: jest.SpyInstance = mockStoredLinks([
579
+ storedLink({
580
+ fromDeviceId: DEVICE_A_ID,
581
+ toDeviceId: DEVICE_B_ID,
582
+ parentDeviceId: DEVICE_A_ID,
583
+ }),
584
+ ]);
585
+
586
+ await (NetworkDeviceLinkService as any).onBeforeUpdate(
587
+ makeUpdateBy({ parentDeviceId: null }),
588
+ );
589
+
590
+ expect(findBySpy).toHaveBeenCalledTimes(1);
591
+ });
592
+
593
+ /*
594
+ * An update matches a QUERY. "Set parent = A on every link into rack 3" can
595
+ * land on rows with completely different ends, and only some of them touch
596
+ * A. Fixing the ones that fit while leaving the rest self-contradicting
597
+ * would be far harder to notice than a refusal, so one bad row rejects the
598
+ * whole write.
599
+ */
600
+ it("validates every matched row and rejects the write when any one of them fails", async () => {
601
+ mockStoredLinks([
602
+ storedLink({
603
+ _id: LINK_ID,
604
+ fromDeviceId: DEVICE_A_ID,
605
+ toDeviceId: DEVICE_B_ID,
606
+ }),
607
+ storedLink({
608
+ _id: OTHER_LINK_ID,
609
+ fromDeviceId: DEVICE_B_ID,
610
+ toDeviceId: DEVICE_C_ID,
611
+ }),
612
+ ]);
613
+
614
+ await expect(
615
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
616
+ makeUpdateBy({ parentDeviceId: DEVICE_A_ID }),
617
+ ),
618
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
619
+ });
620
+
621
+ /*
622
+ * Determinism: the read has no ORDER BY, so which row Postgres hands back
623
+ * first is not something the caller controls. The verdict must not depend on
624
+ * it - a rejection has to be a rejection with the bad row first or last.
625
+ */
626
+ it("rejects the same multi-row write regardless of the order the rows come back", async () => {
627
+ const goodRow: NetworkDeviceLink = storedLink({
628
+ _id: LINK_ID,
629
+ fromDeviceId: DEVICE_A_ID,
630
+ toDeviceId: DEVICE_B_ID,
631
+ });
632
+ const badRow: NetworkDeviceLink = storedLink({
633
+ _id: OTHER_LINK_ID,
634
+ fromDeviceId: DEVICE_B_ID,
635
+ toDeviceId: DEVICE_C_ID,
636
+ });
637
+
638
+ for (const rows of [
639
+ [goodRow, badRow],
640
+ [badRow, goodRow],
641
+ ]) {
642
+ mockStoredLinks(rows);
643
+
644
+ await expect(
645
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
646
+ makeUpdateBy({ parentDeviceId: DEVICE_A_ID }),
647
+ ),
648
+ ).rejects.toThrow(PARENT_NOT_AN_END_MESSAGE);
649
+
650
+ jest.restoreAllMocks();
651
+ }
652
+ });
653
+
654
+ it("accepts a multi-row write when the parent is an end of every matched row", async () => {
655
+ mockStoredLinks([
656
+ storedLink({
657
+ _id: LINK_ID,
658
+ fromDeviceId: DEVICE_A_ID,
659
+ toDeviceId: DEVICE_B_ID,
660
+ }),
661
+ storedLink({
662
+ _id: OTHER_LINK_ID,
663
+ fromDeviceId: DEVICE_C_ID,
664
+ toDeviceId: DEVICE_A_ID,
665
+ }),
666
+ ]);
667
+
668
+ const result: OnUpdate<NetworkDeviceLink> = await (
669
+ NetworkDeviceLinkService as any
670
+ ).onBeforeUpdate(makeUpdateBy({ parentDeviceId: DEVICE_A_ID }));
671
+
672
+ expect(result.carryForward).toBeNull();
673
+ });
674
+
675
+ /*
676
+ * The self-link guard has to hold on the update path too: a link the map
677
+ * would drop as a self-edge is invisible and permanently confusing, whether
678
+ * it was created that way or edited into that shape.
679
+ */
680
+ it("rejects an update that would point both ends at the same device", async () => {
681
+ mockStoredLinks([
682
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
683
+ ]);
684
+
685
+ await expect(
686
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
687
+ makeUpdateBy({ toDeviceId: DEVICE_A_ID }),
688
+ ),
689
+ ).rejects.toThrow("A device cannot be linked to itself.");
690
+ });
691
+
692
+ it("rejects an update that points both written ends at the same device", async () => {
693
+ mockStoredLinks([
694
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
695
+ ]);
696
+
697
+ await expect(
698
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
699
+ makeUpdateBy({
700
+ fromDeviceId: DEVICE_C_ID,
701
+ toDeviceId: DEVICE_C_ID,
702
+ }),
703
+ ),
704
+ ).rejects.toThrow("A device cannot be linked to itself.");
705
+ });
706
+
707
+ /*
708
+ * A row mid-migration - written before both ends were mandatory - has no two
709
+ * ends for a parent to be one of, so there is no invariant to check and the
710
+ * hook must not invent one and block unrelated writes.
711
+ */
712
+ it("skips a stored row that is missing an end rather than failing the write", async () => {
713
+ mockStoredLinks([
714
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: undefined }),
715
+ ]);
716
+
717
+ const result: OnUpdate<NetworkDeviceLink> = await (
718
+ NetworkDeviceLinkService as any
719
+ ).onBeforeUpdate(makeUpdateBy({ parentDeviceId: DEVICE_C_ID }));
720
+
721
+ expect(result.carryForward).toBeNull();
722
+ });
723
+
724
+ // Nothing matched, nothing to contradict.
725
+ it("accepts a write whose query matched no rows", async () => {
726
+ mockStoredLinks([]);
727
+
728
+ const result: OnUpdate<NetworkDeviceLink> = await (
729
+ NetworkDeviceLinkService as any
730
+ ).onBeforeUpdate(makeUpdateBy({ parentDeviceId: DEVICE_C_ID }));
731
+
732
+ expect(result.carryForward).toBeNull();
733
+ });
734
+
735
+ /*
736
+ * Determinism again, on the payload side: the hook reads keys by name, so
737
+ * shuffling them (as any JSON body may arrive shuffled) must not move the
738
+ * verdict.
739
+ */
740
+ it("reaches the same verdict whatever order the payload keys arrive in", async () => {
741
+ const shuffles: Array<Record<string, unknown>> = [
742
+ { fromDeviceId: DEVICE_C_ID, parentDeviceId: DEVICE_B_ID },
743
+ { parentDeviceId: DEVICE_B_ID, fromDeviceId: DEVICE_C_ID },
744
+ ];
745
+
746
+ for (const data of shuffles) {
747
+ mockStoredLinks([
748
+ storedLink({
749
+ fromDeviceId: DEVICE_A_ID,
750
+ toDeviceId: DEVICE_B_ID,
751
+ parentDeviceId: DEVICE_A_ID,
752
+ }),
753
+ ]);
754
+
755
+ const result: OnUpdate<NetworkDeviceLink> = await (
756
+ NetworkDeviceLinkService as any
757
+ ).onBeforeUpdate(makeUpdateBy(data));
758
+
759
+ expect(result.carryForward).toBeNull();
760
+ jest.restoreAllMocks();
761
+ }
762
+ });
763
+
764
+ /*
765
+ * The hook validates; it does not rewrite. Anything it silently normalised
766
+ * into updateBy.data would be written to the row without the caller ever
767
+ * having asked for it.
768
+ */
769
+ it("passes the caller's payload through untouched when the write is accepted", async () => {
770
+ mockStoredLinks([
771
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
772
+ ]);
773
+
774
+ const updateBy: UpdateBy<NetworkDeviceLink> = makeUpdateBy({
775
+ parentDeviceId: DEVICE_A_ID,
776
+ });
777
+
778
+ const result: OnUpdate<NetworkDeviceLink> = await (
779
+ NetworkDeviceLinkService as any
780
+ ).onBeforeUpdate(updateBy);
781
+
782
+ expect(result.updateBy).toBe(updateBy);
783
+ expect(
784
+ Object.keys(result.updateBy.data as unknown as Record<string, unknown>),
785
+ ).toEqual(["parentDeviceId"]);
786
+ });
787
+
788
+ /*
789
+ * The read runs as root so it can see the rows the caller is about to write,
790
+ * which means the caller's tenant has to be re-applied by hand - otherwise
791
+ * a project could be handed another project's links to validate against.
792
+ */
793
+ it("scopes the validation read to the caller's project", async () => {
794
+ const findBySpy: jest.SpyInstance = mockStoredLinks([]);
795
+
796
+ await (NetworkDeviceLinkService as any).onBeforeUpdate({
797
+ query: { _id: LINK_ID.toString() },
798
+ data: { parentDeviceId: DEVICE_A_ID },
799
+ props: { tenantId: PROJECT_ID },
800
+ } as unknown as UpdateBy<NetworkDeviceLink>);
801
+
802
+ const query: any = findBySpy.mock.calls[0]![0].query;
803
+ expect(query._id).toBe(LINK_ID.toString());
804
+ expect(query.projectId.toString()).toBe(PROJECT_ID.toString());
805
+ expect(findBySpy.mock.calls[0]![0].props.isRoot).toBe(true);
806
+ });
807
+
808
+ // The stored parent can only be honoured if the read actually fetches it.
809
+ it("selects the ends and the stored parent the check compares", async () => {
810
+ const findBySpy: jest.SpyInstance = mockStoredLinks([]);
811
+
812
+ await (NetworkDeviceLinkService as any).onBeforeUpdate(
813
+ makeUpdateBy({ fromDeviceId: DEVICE_C_ID }),
814
+ );
815
+
816
+ const select: any = findBySpy.mock.calls[0]![0].select;
817
+ expect(select.fromDeviceId).toBe(true);
818
+ expect(select.toDeviceId).toBe(true);
819
+ expect(select.parentDeviceId).toBe(true);
820
+ });
821
+ });
822
+
823
+ /*
824
+ * The tenancy half of the invariant.
825
+ *
826
+ * onBeforeCreate has always refused an end outside the caller's project,
827
+ * and says why in its own comment: the FK only requires the device row to
828
+ * exist, so without the check a tenant could draw a line to another
829
+ * project's device and read its name off the map. Updates never re-ran it,
830
+ * which left the guard one PUT away from being bypassed - and the declared
831
+ * parent would have inherited the hole, since a parent is only allowed to
832
+ * be an end.
833
+ */
834
+ describe("NetworkDeviceLinkService.onBeforeUpdate (moved ends stay in the project)", () => {
835
+ const FOREIGN_DEVICE_ID: ObjectID = new ObjectID(
836
+ "ffffffff-ffff-4fff-8fff-ffffffffffff",
837
+ );
838
+
839
+ afterEach(() => {
840
+ jest.restoreAllMocks();
841
+ });
842
+
843
+ it("refuses an end moved to a device outside the link's project", async () => {
844
+ mockStoredLinks([
845
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
846
+ ]);
847
+ // The project read finds nothing: that device is not in this project.
848
+ jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue([]);
849
+
850
+ await expect(
851
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
852
+ makeUpdateBy({ toDeviceId: FOREIGN_DEVICE_ID }),
853
+ ),
854
+ ).rejects.toThrow("Both devices must exist and belong to this project.");
855
+ });
856
+
857
+ it("refuses the same move made through the relation spelling", async () => {
858
+ mockStoredLinks([
859
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
860
+ ]);
861
+ jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue([]);
862
+
863
+ await expect(
864
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
865
+ makeUpdateBy({ toDevice: { _id: FOREIGN_DEVICE_ID.toString() } }),
866
+ ),
867
+ ).rejects.toThrow("Both devices must exist and belong to this project.");
868
+ });
869
+
870
+ it("scopes the device read to the link's own project", async () => {
871
+ mockStoredLinks([
872
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
873
+ ]);
874
+ const deviceFindBy: jest.SpyInstance = mockProjectDevicesExist();
875
+
876
+ await (NetworkDeviceLinkService as any).onBeforeUpdate(
877
+ makeUpdateBy({ toDeviceId: DEVICE_C_ID }),
878
+ );
879
+
880
+ expect(deviceFindBy).toHaveBeenCalled();
881
+ const query: any = deviceFindBy.mock.calls[0]![0].query;
882
+ expect(query.projectId.toString()).toBe(PROJECT_ID.toString());
883
+ });
884
+
885
+ it("accepts an end moved to another device in the same project", async () => {
886
+ mockStoredLinks([
887
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
888
+ ]);
889
+
890
+ const result: OnUpdate<NetworkDeviceLink> = await (
891
+ NetworkDeviceLinkService as any
892
+ ).onBeforeUpdate(makeUpdateBy({ toDeviceId: DEVICE_C_ID }));
893
+
894
+ expect(result.carryForward).toBeNull();
895
+ });
896
+
897
+ /*
898
+ * A count would be satisfied by two rows for one device. The check
899
+ * compares the ids it asked about against the ids it got back.
900
+ */
901
+ it("is not fooled by a duplicate row standing in for a missing device", async () => {
902
+ mockStoredLinks([
903
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
904
+ ]);
905
+ jest.spyOn(NetworkDeviceService, "findBy").mockResolvedValue([
906
+ {
907
+ _id: DEVICE_C_ID.toString(),
908
+ id: DEVICE_C_ID,
909
+ } as unknown as NetworkDevice,
910
+ {
911
+ _id: DEVICE_C_ID.toString(),
912
+ id: DEVICE_C_ID,
913
+ } as unknown as NetworkDevice,
914
+ ]);
915
+
916
+ await expect(
917
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
918
+ makeUpdateBy({
919
+ fromDeviceId: DEVICE_C_ID,
920
+ toDeviceId: FOREIGN_DEVICE_ID,
921
+ }),
922
+ ),
923
+ ).rejects.toThrow("Both devices must exist and belong to this project.");
924
+ });
925
+
926
+ // No end moved, so there is nothing to re-check and nothing to read.
927
+ it("does not read devices when only the parent is written", async () => {
928
+ mockStoredLinks([
929
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
930
+ ]);
931
+ const deviceFindBy: jest.SpyInstance = mockProjectDevicesExist();
932
+
933
+ await (NetworkDeviceLinkService as any).onBeforeUpdate(
934
+ makeUpdateBy({ parentDeviceId: DEVICE_A_ID }),
935
+ );
936
+
937
+ expect(deviceFindBy).not.toHaveBeenCalled();
938
+ });
939
+ });
940
+
941
+ /*
942
+ * Two shapes an id can arrive in that a naive comparison gets wrong.
943
+ */
944
+ describe("NetworkDeviceLinkService - how ids are compared", () => {
945
+ afterEach(() => {
946
+ jest.restoreAllMocks();
947
+ });
948
+
949
+ /*
950
+ * These columns are Postgres `uuid`, where equality ignores case, and
951
+ * ObjectID.isValidUUID accepts either case - so a client may legitimately
952
+ * send an upper-case id for a row stored lower-case. A raw JS !== calls
953
+ * that a different device and refuses a parent that IS an end. Note the
954
+ * device-existence check could never disagree: it runs in Postgres.
955
+ */
956
+ it("accepts a parent that names an end in a different case", async () => {
957
+ mockStoredLinks([
958
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
959
+ ]);
960
+
961
+ const result: OnUpdate<NetworkDeviceLink> = await (
962
+ NetworkDeviceLinkService as any
963
+ ).onBeforeUpdate(
964
+ makeUpdateBy({
965
+ parentDeviceId: new ObjectID(DEVICE_A_ID.toString().toUpperCase()),
966
+ }),
967
+ );
968
+
969
+ expect(result.carryForward).toBeNull();
970
+ });
971
+
972
+ it("still catches a self-link written in mixed case", async () => {
973
+ mockBothEndsExist();
974
+
975
+ await expect(
976
+ (NetworkDeviceLinkService as any).onBeforeCreate(
977
+ makeCreateBy({
978
+ fromDeviceId: DEVICE_A_ID,
979
+ toDeviceId: new ObjectID(DEVICE_A_ID.toString().toUpperCase()),
980
+ }),
981
+ ),
982
+ ).rejects.toThrow("A device cannot be linked to itself.");
983
+ });
984
+
985
+ /*
986
+ * UpdateBy.data is a QueryDeepPartialEntity, so a column may legally hold
987
+ * a raw SQL-expression function. RelationIdUtil reads one as null -
988
+ * indistinguishable from "cleared" - and the invariant would then be
989
+ * checked against a row bearing no relation to what gets written. Nothing
990
+ * writes this model that way today; refusing is what keeps that true.
991
+ */
992
+ it("refuses an end set to a raw SQL expression rather than mis-validating it", async () => {
993
+ mockStoredLinks([
994
+ storedLink({
995
+ fromDeviceId: DEVICE_A_ID,
996
+ toDeviceId: DEVICE_B_ID,
997
+ parentDeviceId: DEVICE_B_ID,
998
+ }),
999
+ ]);
1000
+
1001
+ await expect(
1002
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
1003
+ makeUpdateBy({
1004
+ toDeviceId: (): string => {
1005
+ return `'${DEVICE_C_ID.toString()}'`;
1006
+ },
1007
+ }),
1008
+ ),
1009
+ ).rejects.toThrow(BadDataException);
1010
+ });
1011
+
1012
+ it("refuses a parent set to a raw SQL expression", async () => {
1013
+ mockStoredLinks([
1014
+ storedLink({ fromDeviceId: DEVICE_A_ID, toDeviceId: DEVICE_B_ID }),
1015
+ ]);
1016
+
1017
+ await expect(
1018
+ (NetworkDeviceLinkService as any).onBeforeUpdate(
1019
+ makeUpdateBy({
1020
+ parentDeviceId: (): string => {
1021
+ return `'${DEVICE_C_ID.toString()}'`;
1022
+ },
1023
+ }),
1024
+ ),
1025
+ ).rejects.toThrow(BadDataException);
1026
+ });
1027
+
1028
+ it("refuses the same on create", async () => {
1029
+ mockBothEndsExist();
1030
+
1031
+ await expect(
1032
+ (NetworkDeviceLinkService as any).onBeforeCreate(
1033
+ makeCreateBy({
1034
+ fromDeviceId: DEVICE_A_ID,
1035
+ toDeviceId: DEVICE_B_ID,
1036
+ parentDeviceId: (): string => {
1037
+ return `'${DEVICE_C_ID.toString()}'`;
1038
+ },
1039
+ }),
1040
+ ),
1041
+ ).rejects.toThrow(BadDataException);
1042
+ });
1043
+ });