@oneuptime/common 12.0.34 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/Models/DatabaseModels/GlobalConfig.ts +12 -12
  2. package/Models/DatabaseModels/InventoryItem.ts +44 -1
  3. package/Models/DatabaseModels/NetworkDevice.ts +126 -0
  4. package/Server/EnvironmentConfig.ts +47 -16
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.ts +25 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Infrastructure/Redis.ts +8 -8
  8. package/Server/Infrastructure/Status.ts +10 -4
  9. package/Server/Services/DatabaseService.ts +65 -13
  10. package/Server/Services/InventoryItemService.ts +31 -1
  11. package/Server/Services/MonitorTemplateService.ts +140 -19
  12. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +349 -41
  13. package/Server/Services/NetworkDeviceDiscoveryScanService.ts +87 -2
  14. package/Server/Services/NetworkDeviceService.ts +69 -0
  15. package/Server/Services/UserNotificationRuleService.ts +76 -25
  16. package/Server/Types/Database/Permissions/ReadPermission.ts +45 -5
  17. package/Server/Types/Database/QueryUtil.ts +56 -0
  18. package/Server/Types/Markdown.ts +93 -1
  19. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +82 -17
  20. package/Server/Utils/EmailRollup/EmailRollupFlushRunner.ts +10 -1
  21. package/Server/Utils/EmailRollup/EmailRollupRenderer.ts +2 -2
  22. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +82 -35
  23. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +31 -5
  24. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +202 -37
  25. package/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.ts +82 -9
  26. package/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.ts +16 -7
  27. package/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.ts +29 -17
  28. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +107 -48
  29. package/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.ts +158 -0
  30. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +27 -0
  31. package/Server/Utils/Telemetry/EntityRegistry.ts +11 -1
  32. package/Server/Utils/Telemetry/TelemetryEntity.ts +83 -9
  33. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +32 -0
  34. package/Tests/App/Dashboard/AdoptedDeviceDrawer.test.tsx +374 -0
  35. package/Tests/App/Dashboard/DashboardLabelVariables.test.tsx +580 -0
  36. package/Tests/App/Dashboard/DashboardMonitorLabelVariable.test.tsx +438 -0
  37. package/Tests/App/Dashboard/DashboardVariableAllContract.test.tsx +5 -2
  38. package/Tests/App/Dashboard/DeviceAttachmentCard.test.tsx +273 -0
  39. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +505 -0
  40. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +163 -0
  41. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +401 -0
  42. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +232 -0
  43. package/Tests/App/Dashboard/InventoryItems.test.tsx +201 -0
  44. package/Tests/App/Dashboard/InventoryTypeAndStatusFacets.test.tsx +798 -0
  45. package/Tests/App/Dashboard/NetworkTopologyLiveView.test.tsx +320 -0
  46. package/Tests/App/Dashboard/NetworkTopologyToolbar.test.tsx +275 -0
  47. package/Tests/App/Dashboard/OnCallCalendarFeedEmptyState.test.tsx +975 -0
  48. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +63 -0
  49. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +626 -0
  50. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +348 -0
  51. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +331 -0
  52. package/Tests/App/Dashboard/UserSettingsEmailPreferences.test.tsx +603 -0
  53. package/Tests/App/Dashboard/UserSettingsNotificationSettings.test.tsx +324 -0
  54. package/Tests/App/PublicDashboard/DashboardVariableSelector.test.tsx +13 -4
  55. package/Tests/App/StatusPage/StatusPageOidcOrigin.test.tsx +223 -0
  56. package/Tests/Models/InventoryItemStatus.test.ts +147 -0
  57. package/Tests/Models/NetworkDeviceMacAddressColumn.test.ts +294 -0
  58. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +129 -0
  59. package/Tests/Server/EnvironmentConfigValkey.test.ts +227 -0
  60. package/Tests/Server/Infrastructure/Postgres/AddMacAddressToNetworkDeviceMigration.test.ts +365 -0
  61. package/Tests/Server/Infrastructure/Postgres/CustomFieldValueMappingMigration.test.ts +62 -23
  62. package/Tests/Server/Infrastructure/ValkeyDeploymentConfig.test.ts +265 -0
  63. package/Tests/Server/Middleware/HttpMetricsMiddleware.test.ts +382 -0
  64. package/Tests/Server/Middleware/SCIMAuthorization.test.ts +525 -0
  65. package/Tests/Server/Services/DatabaseServiceAggregateBy.test.ts +291 -56
  66. package/Tests/Server/Services/DatabaseServiceSortTiebreaker.test.ts +151 -0
  67. package/Tests/Server/Services/InventoryItemManualCreate.test.ts +105 -0
  68. package/Tests/Server/Services/InventoryItemPromotionGate.test.ts +178 -0
  69. package/Tests/Server/Services/MonitorTemplateServiceCustomFieldSync.test.ts +502 -0
  70. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +1133 -81
  71. package/Tests/Server/Services/NetworkDeviceDiscoveryScanRegistration.test.ts +520 -0
  72. package/Tests/Server/Services/NetworkDeviceMacAddressNormalization.test.ts +572 -0
  73. package/Tests/Server/Types/Database/InventoryStatusPostgres.test.ts +368 -0
  74. package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +261 -14
  75. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroups.test.ts +169 -0
  76. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroupsPostgres.test.ts +297 -0
  77. package/Tests/Server/Types/Markdown.test.ts +194 -1
  78. package/Tests/Server/Types/Workflow/Components/IncomingWebhookUtils.test.ts +279 -0
  79. package/Tests/Server/Types/Workflow/Components/TextToJson.test.ts +212 -0
  80. package/Tests/Server/Utils/CustomField/CustomFieldMappingRegistry.test.ts +436 -0
  81. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +214 -0
  82. package/Tests/Server/Utils/EmailRollup/EmailRollupFlushRunnerBehaviour.test.ts +1 -1
  83. package/Tests/Server/Utils/EmailRollup/EmailRollupRenderer.test.ts +10 -3
  84. package/Tests/Server/Utils/EventLoop.test.ts +359 -0
  85. package/Tests/Server/Utils/JsonWebToken.test.ts +30 -0
  86. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +242 -0
  87. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteriaAggregation.test.ts +13 -7
  88. package/Tests/Server/Utils/Monitor/Criteria/DatabaseMonitorCriteria.test.ts +29 -0
  89. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +30 -6
  90. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +246 -0
  91. package/Tests/Server/Utils/Monitor/MonitorCriteriaEmailEvalLogAgreement.test.ts +280 -0
  92. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +224 -10
  93. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +570 -0
  94. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilderUnits.test.ts +30 -5
  95. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageBuilderUnits.test.ts +68 -21
  96. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +36 -19
  97. package/Tests/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.test.ts +573 -0
  98. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +317 -0
  99. package/Tests/Server/Utils/Telemetry/ContextSpanProcessor.test.ts +281 -0
  100. package/Tests/Server/Utils/Telemetry/InventoryDuplicateIdentity.test.ts +316 -0
  101. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +111 -8
  102. package/Tests/Types/Database/IncludesAnyOfGroups.test.ts +106 -0
  103. package/Tests/Types/JSONFunctions.test.ts +40 -0
  104. package/Tests/Types/NetworkAutomation/AutoImportRunMerge.test.ts +235 -0
  105. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +66 -0
  106. package/Tests/Types/NetworkAutomation/RuleRunResultDescribe.test.ts +309 -1
  107. package/Tests/Types/Telemetry/InventoryLiveness.test.ts +99 -0
  108. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +160 -0
  109. package/Tests/UI/ReactRouterSingletonBuild.test.ts +133 -0
  110. package/Tests/UI/Rum/ReplayHeader.test.tsx +10 -2
  111. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +514 -0
  112. package/Tests/UI/Rum/ReplayTimeFormat.test.ts +94 -0
  113. package/Tests/UI/Rum/ReplayUi.test.tsx +559 -0
  114. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  115. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +1 -0
  116. package/Tests/Utils/Dashboard/Components/DashboardMonitorListComponent.test.ts +11 -1
  117. package/Tests/Utils/Dashboard/LabelVariable.test.ts +257 -0
  118. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +144 -14
  119. package/Tests/Utils/MetricUnitUtil.test.ts +81 -0
  120. package/Tests/Utils/Monitor/DeviceMacLearningUtil.test.ts +497 -0
  121. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +546 -0
  122. package/Tests/Utils/Monitor/MonitorTemplateCustomFieldUtil.test.ts +218 -0
  123. package/Tests/Utils/Monitor/NetworkTopologyEndpointAdoption.test.ts +1755 -0
  124. package/Tests/Utils/NetworkAutomation/AutoImportRunChain.test.ts +320 -0
  125. package/Types/BaseDatabase/IncludesAnyOfGroups.ts +77 -0
  126. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  127. package/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.ts +1 -0
  128. package/Types/Dashboard/DashboardVariable.ts +8 -0
  129. package/Types/JSON.ts +4 -0
  130. package/Types/JSONFunctions.ts +16 -0
  131. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  132. package/Types/NetworkAutomation/RuleRunResult.ts +402 -13
  133. package/Types/SerializableObjectDictionary.ts +4 -0
  134. package/Types/Telemetry/InventoryLiveness.ts +56 -0
  135. package/UI/Components/CSVFileUpload/CSVFileUpload.tsx +9 -2
  136. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +46 -21
  137. package/UI/Components/Dashboard/DashboardVariableControl.tsx +285 -0
  138. package/UI/esbuild-config.js +6 -0
  139. package/Utils/Dashboard/Components/DashboardMonitorListComponent.ts +12 -1
  140. package/Utils/Dashboard/LabelVariable.ts +152 -0
  141. package/Utils/Dashboard/VariableUrlState.ts +27 -5
  142. package/Utils/MetricUnitUtil.ts +34 -0
  143. package/Utils/Monitor/DeviceMacLearningUtil.ts +177 -0
  144. package/Utils/Monitor/MetricValueFormatter.ts +342 -0
  145. package/Utils/Monitor/MonitorTemplateCustomFieldUtil.ts +124 -0
  146. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +7 -2
  147. package/Utils/Monitor/NetworkTopologyUtil.ts +693 -0
  148. package/Utils/NetworkAutomation/AutoImportRunChain.ts +138 -0
  149. package/build/dist/Models/DatabaseModels/GlobalConfig.js +12 -12
  150. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/InventoryItem.js +34 -1
  152. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/NetworkDevice.js +128 -0
  154. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  155. package/build/dist/Server/EnvironmentConfig.js +44 -12
  156. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  157. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js +14 -0
  158. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js.map +1 -0
  159. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  160. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  161. package/build/dist/Server/Infrastructure/Redis.js +8 -8
  162. package/build/dist/Server/Infrastructure/Redis.js.map +1 -1
  163. package/build/dist/Server/Infrastructure/Status.js +10 -4
  164. package/build/dist/Server/Infrastructure/Status.js.map +1 -1
  165. package/build/dist/Server/Services/DatabaseService.js +58 -13
  166. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  167. package/build/dist/Server/Services/InventoryItemService.js +28 -1
  168. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  169. package/build/dist/Server/Services/MonitorTemplateService.js +116 -17
  170. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  171. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +270 -37
  172. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  173. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js +65 -0
  174. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js.map +1 -1
  175. package/build/dist/Server/Services/NetworkDeviceService.js +54 -0
  176. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  177. package/build/dist/Server/Services/UserNotificationRuleService.js +60 -25
  178. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  179. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js +29 -4
  180. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js.map +1 -1
  181. package/build/dist/Server/Types/Database/QueryUtil.js +36 -0
  182. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  183. package/build/dist/Server/Types/Markdown.js +75 -0
  184. package/build/dist/Server/Types/Markdown.js.map +1 -1
  185. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +53 -18
  186. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  187. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js +10 -1
  188. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js.map +1 -1
  189. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js +2 -2
  190. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js.map +1 -1
  191. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +58 -35
  192. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  193. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +29 -5
  194. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  195. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +166 -35
  196. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  197. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js +58 -9
  198. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js.map +1 -1
  199. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js +9 -3
  200. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js.map +1 -1
  201. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js +26 -12
  202. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js.map +1 -1
  203. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +74 -33
  204. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  205. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js +114 -0
  206. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js.map +1 -0
  207. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +25 -0
  208. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  209. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +9 -1
  210. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  211. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +70 -13
  212. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  213. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js +54 -0
  214. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js.map +1 -0
  215. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  216. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  217. package/build/dist/Types/Dashboard/DashboardVariable.js +1 -0
  218. package/build/dist/Types/Dashboard/DashboardVariable.js.map +1 -1
  219. package/build/dist/Types/JSON.js +1 -0
  220. package/build/dist/Types/JSON.js.map +1 -1
  221. package/build/dist/Types/JSONFunctions.js +13 -0
  222. package/build/dist/Types/JSONFunctions.js.map +1 -1
  223. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +291 -6
  224. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
  225. package/build/dist/Types/SerializableObjectDictionary.js +4 -0
  226. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  227. package/build/dist/Types/Telemetry/InventoryLiveness.js +37 -0
  228. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -0
  229. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js +7 -0
  230. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js.map +1 -1
  231. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +9 -2
  232. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  233. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js +137 -0
  234. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js.map +1 -0
  235. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js +9 -1
  236. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js.map +1 -1
  237. package/build/dist/Utils/Dashboard/LabelVariable.js +96 -0
  238. package/build/dist/Utils/Dashboard/LabelVariable.js.map +1 -0
  239. package/build/dist/Utils/Dashboard/VariableUrlState.js +19 -4
  240. package/build/dist/Utils/Dashboard/VariableUrlState.js.map +1 -1
  241. package/build/dist/Utils/MetricUnitUtil.js +26 -0
  242. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  243. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js +112 -0
  244. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js.map +1 -0
  245. package/build/dist/Utils/Monitor/MetricValueFormatter.js +276 -0
  246. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -0
  247. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js +101 -0
  248. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js.map +1 -0
  249. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +7 -2
  250. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  251. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +484 -0
  252. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  253. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js +59 -0
  254. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js.map +1 -0
  255. package/jest.config.json +2 -0
  256. package/package.json +1 -1
  257. package/test-setup.sh +9 -9
  258. package/tsconfig.json +51 -11
  259. package/Tests/App/Dashboard/NotificationEmailPreferences.test.tsx +0 -399
@@ -1,9 +1,20 @@
1
1
  import NetworkDeviceService from "../../../Server/Services/NetworkDeviceService";
2
2
  import AggregateBy from "../../../Server/Types/Database/AggregateBy";
3
+ import ModelPermission from "../../../Server/Types/Database/Permissions/Index";
4
+ import { CheckReadPermissionType } from "../../../Server/Types/Database/Permissions/ReadPermission";
3
5
  import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
6
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
7
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
8
+ import { TableColumnMetadata } from "../../../Types/Database/TableColumn";
9
+ import TableColumnType from "../../../Types/Database/TableColumnType";
4
10
  import BadDataException from "../../../Types/Exception/BadDataException";
5
11
  import DatabaseNotConnectedException from "../../../Types/Exception/DatabaseNotConnectedException";
12
+ import ObjectID from "../../../Types/ObjectID";
13
+ import Permission, {
14
+ UserTenantAccessPermission,
15
+ } from "../../../Types/Permission";
6
16
  import { describe, expect, it } from "@jest/globals";
17
+ import { FindOperator } from "typeorm";
7
18
  import fs from "fs";
8
19
  import path from "path";
9
20
 
@@ -318,20 +329,38 @@ describe("aggregateBy rejects an expression carrying a statement separator", ()
318
329
  }
319
330
  });
320
331
 
321
- /*
322
- * NOT tested here, deliberately: the same semicolon inside an `orderBy`
323
- * expression. aggregateBy validates orderBy expressions in a loop that runs
324
- * AFTER `this.buildAggregateScope(...)`, which calls getQueryBuilder, which
325
- * throws DatabaseNotConnectedException when Postgres is not up — so with no
326
- * database the call never reaches the orderBy check, and an assertion here
327
- * would be green for the wrong reason.
328
- *
329
- * Faking a connection would mean standing up half of TypeORM's DataSource,
330
- * which buys a test of the mock rather than of the method. Instead, the
331
- * source-text block at the bottom pins that the orderBy loop still calls
332
- * assertSafeAggregateExpression deleting that call is the regression, and
333
- * that IS detectable from here.
334
- */
332
+ it("rejects a semicolon in an orderBy expression", async () => {
333
+ /*
334
+ * This case used to carry a comment here explaining that it could not be
335
+ * tested: the orderBy check ran inside the loop that APPLIES the order
336
+ * clauses, below `this.buildAggregateScope(...)`, so with no Postgres the
337
+ * call died on DatabaseNotConnectedException before ever reaching it.
338
+ *
339
+ * Production moved. aggregateBy now validates every orderBy expression in
340
+ * a loop of its own, above the permission call and above the query
341
+ * builder, with a comment saying it is placed there precisely so the
342
+ * guard is reachable without a live connection. So the gap closes by
343
+ * actually calling the method, which is worth more than the source-text
344
+ * substitute further down: this fails if the check is deleted, if it
345
+ * stops covering orderBy, or if it starts accepting a separator.
346
+ *
347
+ * The ordering that makes it reachable is itself asserted at the bottom
348
+ * of the file, so this cannot quietly start passing for the wrong reason.
349
+ */
350
+ const error: Error = await aggregateError({
351
+ orderBy: [
352
+ {
353
+ expression: `COUNT(*); DROP TABLE "NetworkDevice"`,
354
+ sortOrder: SortOrder.Ascending,
355
+ },
356
+ ],
357
+ });
358
+
359
+ expect(error).toBeInstanceOf(BadDataException);
360
+ expect(error.message).toBe(
361
+ "Aggregate expressions cannot contain statement separators.",
362
+ );
363
+ });
335
364
  });
336
365
 
337
366
  describe("aggregateBy rejects an empty expression", () => {
@@ -432,21 +461,6 @@ const DATABASE_SERVICE_SOURCE: string = fs.readFileSync(
432
461
  "utf8",
433
462
  );
434
463
 
435
- const READ_PERMISSION_SOURCE: string = fs.readFileSync(
436
- path.join(
437
- __dirname,
438
- "..",
439
- "..",
440
- "..",
441
- "Server",
442
- "Types",
443
- "Database",
444
- "Permissions",
445
- "ReadPermission.ts",
446
- ),
447
- "utf8",
448
- );
449
-
450
464
  /*
451
465
  * Just the body of aggregateBy, and just the body of the helper that builds
452
466
  * its scoped query builder. DatabaseService.ts is ~2,600 lines and uses
@@ -473,16 +487,101 @@ const BUILD_AGGREGATE_SCOPE_BODY: string = squash(
473
487
  ),
474
488
  );
475
489
 
490
+ /*
491
+ * A reader whose grant on this model is LABEL-SCOPED: one tenant permission
492
+ * row carrying labelIds, and nothing else. That is the only kind of caller for
493
+ * whom the permission pipeline produces a condition on the access-control
494
+ * relation, which is the shape the assertions below are about.
495
+ *
496
+ * Built the way ReadBlockPermission.test.ts builds its props, and deliberately
497
+ * ProjectMember rather than a granular ReadNetworkDevice: ProjectMember is in
498
+ * the read list of the table AND of every column the query below names, so a
499
+ * failure here is a failure about the SHAPE of the returned query rather than
500
+ * about a column-permission detail that has nothing to do with aggregates.
501
+ *
502
+ * That is a coupling to NetworkDevice's permission lists, so the caller
503
+ * asserts it as an explicit precondition before calling the pipeline. A
504
+ * tightening of those lists then reports itself as "ProjectMember is no longer
505
+ * in the read list" rather than as a NotAuthorizedException from four files
506
+ * away.
507
+ */
508
+ function labelScopedReaderProps(
509
+ projectId: ObjectID,
510
+ permittedLabelId: ObjectID,
511
+ ): DatabaseCommonInteractionProps {
512
+ const tenantPermission: UserTenantAccessPermission = {
513
+ projectId: projectId,
514
+ _type: "UserTenantAccessPermission",
515
+ permissions: [
516
+ {
517
+ _type: "UserPermission",
518
+ permission: Permission.ProjectMember,
519
+ labelIds: [permittedLabelId],
520
+ isBlockPermission: false,
521
+ },
522
+ ],
523
+ };
524
+
525
+ return {
526
+ userId: ObjectID.generate(),
527
+ tenantId: projectId,
528
+ userTenantAccessPermission: {
529
+ [projectId.toString()]: tenantPermission,
530
+ },
531
+ };
532
+ }
533
+
534
+ /**
535
+ * Every id a find operator carries, whatever slot the operator keeps them in.
536
+ *
537
+ * The point is to assert WHICH labels the permission pipeline let through
538
+ * without asserting how the operator spells itself in SQL. QueryHelper builds
539
+ * set membership as a TypeORM `Raw` today, whose ids live in
540
+ * `objectLiteralParameters` and whose `value` is an empty array; TypeORM's own
541
+ * `In([...])` would put the same ids in `value` and leave
542
+ * `objectLiteralParameters` undefined. Both are the same invariant. Reading
543
+ * both slots means a change of spelling inside QueryHelper — a file this suite
544
+ * does not own, and whose rendered SQL an earlier version of the test below
545
+ * pinned, to its cost — cannot redden this test while the invariant holds,
546
+ * while anything that changes which ids are bound still does.
547
+ *
548
+ * Only strings are collected: an id is the one thing these operators bind as a
549
+ * string, and a helper that also bound, say, a match count would bind that as
550
+ * a number.
551
+ */
552
+ function boundIdsOf(operator: FindOperator<unknown>): Array<string> {
553
+ const parameters: Record<string, unknown> =
554
+ (operator.objectLiteralParameters as Record<string, unknown> | undefined) ||
555
+ {};
556
+
557
+ const carried: Array<unknown> = [
558
+ ...Object.values(parameters),
559
+ operator.value,
560
+ ];
561
+
562
+ const ids: Array<string> = [];
563
+
564
+ for (const entry of carried) {
565
+ for (const value of Array.isArray(entry) ? entry : [entry]) {
566
+ if (typeof value === "string") {
567
+ ids.push(value);
568
+ }
569
+ }
570
+ }
571
+
572
+ return ids.sort();
573
+ }
574
+
476
575
  describe("the contract aggregateBy's own comments claim", () => {
477
576
  it("applies the permission query through setFindOptions, never by handing the object to .where()", () => {
478
577
  /*
479
578
  * This is the security-relevant one, and it is invisible from a black-box
480
579
  * call because both spellings produce a query that runs.
481
580
  *
482
- * ReadPermission.checkReadBlockPermission (asserted below) can hand back a
483
- * NESTED condition on the access-control relation — for a user holding a
484
- * label-blocked read permission it writes
485
- * `query[accessControlColumn] = { _id: notInOrNull(labelIds) }`.
581
+ * The permission pipeline (run for real below) can hand back a NESTED
582
+ * condition on the access-control relation — for a user whose read grant
583
+ * is label-scoped, the query comes back carrying
584
+ * `query[accessControlColumn] = { _id: <the permitted labels> }`.
486
585
  * TypeORM's `.where(object)` renders FLAT conditions only: it has no
487
586
  * relation to join the nested object against, so it drops the clause
488
587
  * rather than erroring. The aggregate would then count rows the equivalent
@@ -515,20 +614,150 @@ describe("the contract aggregateBy's own comments claim", () => {
515
614
  );
516
615
  });
517
616
 
518
- it("still has a nested access-control condition to protect", () => {
617
+ it("still has a nested access-control condition to protect", async () => {
519
618
  /*
520
- * The assertion above is only worth having while checkReadBlockPermission
521
- * really does write a nested condition. If that ever became a flat clause,
522
- * the setFindOptions requirement would stop being load-bearing and the
523
- * comment explaining it would become folklore.
619
+ * The assertion above is only worth having while the permission pipeline
620
+ * really does hand back a condition keyed on the access-control RELATION.
621
+ * If every access-control predicate became a flat clause on a column of
622
+ * the table itself, `.where(object)` would apply it perfectly well, the
623
+ * de-duplicating subquery in buildAggregateScope would have nothing left
624
+ * to de-duplicate, and the comment above would become folklore.
625
+ *
626
+ * This used to be a source-text match on ReadPermission.ts, pinned to the
627
+ * exact expression checkReadBlockPermission wrote. That expression is
628
+ * gone: a label BLOCK is now a flat `_id NOT IN (SELECT ... FROM the join
629
+ * table)` predicate. That was a fix, not a regression — a relation join
630
+ * cannot express "has none of these labels" at all (a device labelled
631
+ * {blocked, other} still matches the join through its "other" row) — and
632
+ * a flat predicate needs no join, so the block half cannot inflate an
633
+ * aggregate either way.
634
+ *
635
+ * So nothing in this file asserts the block half any more, and that is a
636
+ * decision rather than an oversight. There is no aggregate-specific
637
+ * behaviour left to assert about it: with no relation key in the query,
638
+ * queryTouchesARelation says no, buildAggregateScope takes the flat
639
+ * `setFindOptions` branch, the same FindOptions machinery findBy uses
640
+ * applies the predicate, and there is no join to de-duplicate. A reader
641
+ * who holds a block AND a label-scoped allow puts the relation key back
642
+ * in, which is the case this test already covers. The block predicate
643
+ * itself belongs to ReadBlockPermission.test.ts, which owns it, and which
644
+ * has to mock QueryUtil.getManyToManyRelationMetadata to reach it —
645
+ * that helper returns null whenever Postgres is not connected, so a block
646
+ * assertion here would have to bring the mock with it, into a suite whose
647
+ * whole point is that it needs no database.
648
+ *
649
+ * The nested shape did not leave with it. It belongs to the ALLOW half,
650
+ * which is the half that has always produced the join aggregateBy has to
651
+ * survive: AccessControlPermission.addAccessControlIdsToQuery puts the
652
+ * permitted label ids on the access-control column, and
653
+ * QueryUtil.serializeQuery turns an id array on an EntityArray column
654
+ * into `{ _id: <operator> }`.
655
+ *
656
+ * So this asks the pipeline rather than grepping whichever file happens
657
+ * to spell it today: run the same call aggregateBy makes and look at what
658
+ * comes back. It goes red if the label-scoped read stops producing a
659
+ * relation-keyed condition — whether because the allow path moves to a
660
+ * flat `_id` predicate the way the block path did, because serializeQuery
661
+ * stops nesting the id filter under the relation, or because NetworkDevice
662
+ * loses its access-control relation.
663
+ *
664
+ * If that day comes, a red here is NOT permission to delete the
665
+ * setFindOptions requirement above, and it is not permission to quietly
666
+ * rewrite this expectation either. This test watches ONE producer of
667
+ * relation-keyed conditions, and there is at least one more:
668
+ * `@CanAccessIfCanReadOn` makes BasePermission write
669
+ * `query[relation] = { <the related model's access-control column>: ids }`
670
+ * for StatusPageResource, IncidentInternalNote, AlertEpisodeMember,
671
+ * OnCallDutyPolicyExecutionLogTimeline and others. queryTouchesARelation
672
+ * cannot tell that key apart from this one, and `.where(object)` would
673
+ * drop it just as silently — no exception, no log line, an aggregate over
674
+ * rows the caller may not read.
675
+ *
676
+ * What has to be RE-ESTABLISHED before anything here is deleted is
677
+ * therefore the whole claim rather than this one case: that NO read path —
678
+ * label allow, label block, `@CanAccessIfCanReadOn`, owner scoping,
679
+ * whatever has been added since — can still put a relation key in the
680
+ * permitted query. Nobody has shown that. Until somebody does, a red here
681
+ * means this particular case moved, and the test should follow it to
682
+ * wherever the nested condition is produced now.
683
+ *
684
+ * No database is needed: the permission pipeline is pure right up to the
685
+ * point DatabaseService hands the query to TypeORM.
686
+ */
687
+ const projectId: ObjectID = ObjectID.generate();
688
+ const permittedLabelId: ObjectID = ObjectID.generate();
689
+ const model: NetworkDevice = new NetworkDevice();
690
+
691
+ /*
692
+ * The two preconditions this test rests on, asserted rather than assumed.
693
+ * ProjectMember is the permission the reader below is given; the moment it
694
+ * stops being enough to read this table or the projectId column the query
695
+ * filters on, checkReadQueryPermission throws NotAuthorizedException with
696
+ * a message about permission names and nothing about aggregates. Whoever
697
+ * tightens those lists should learn it from one line here rather than from
698
+ * a confusing exception inside a test named after access-control
699
+ * conditions — and the fix then is to give this reader a permission that
700
+ * IS in both lists, not to change the lists back.
524
701
  */
525
- const READ_PERMISSION_CODE: string = squash(
526
- stripComments(READ_PERMISSION_SOURCE),
702
+ expect(model.getReadPermissions()).toContain(Permission.ProjectMember);
703
+ expect(model.getColumnAccessControlFor("projectId")?.read || []).toContain(
704
+ Permission.ProjectMember,
527
705
  );
528
706
 
529
- expect(READ_PERMISSION_CODE).toContain(
530
- "[model.getAccessControlColumn() as string] = { _id: QueryHelper.notInOrNull(labelIds)",
707
+ const permitted: CheckReadPermissionType<NetworkDevice> =
708
+ await ModelPermission.checkReadQueryPermission(
709
+ NetworkDevice,
710
+ { projectId: projectId },
711
+ null,
712
+ labelScopedReaderProps(projectId, permittedLabelId),
713
+ );
714
+
715
+ const accessControlColumn: string | null = model.getAccessControlColumn();
716
+
717
+ expect(accessControlColumn).toBeTruthy();
718
+
719
+ /*
720
+ * The key names a RELATION, not a column of this table. That is the whole
721
+ * reason `.where(object)` cannot apply the condition and FindOptions has
722
+ * to join for it.
723
+ */
724
+ const columnMetadata: TableColumnMetadata = model.getTableColumnMetadata(
725
+ accessControlColumn as string,
531
726
  );
727
+
728
+ expect(columnMetadata.type).toBe(TableColumnType.EntityArray);
729
+
730
+ // And the condition under that key is NESTED: a filter on the RELATED row.
731
+ const accessControlCondition: Record<string, unknown> = (
732
+ permitted.query as any
733
+ )[accessControlColumn as string];
734
+
735
+ /*
736
+ * `toBeTruthy`, not `toBeDefined`: the latter passes on null, and a null
737
+ * under that key is one of the ways this could break. The `typeof` check
738
+ * is the load-bearing half — a plain id string here instead of an object
739
+ * would BE the "it went flat" regression this test exists to catch, and
740
+ * a truthiness check alone would sail past it.
741
+ */
742
+ expect(accessControlCondition).toBeTruthy();
743
+ expect(typeof accessControlCondition).toBe("object");
744
+
745
+ const nestedIdFilter: unknown = accessControlCondition["_id"];
746
+
747
+ expect(nestedIdFilter).toBeInstanceOf(FindOperator);
748
+
749
+ /*
750
+ * The identity above and the payload below, and deliberately nothing in
751
+ * between. An earlier version of this assertion also read
752
+ * `getSql("probe")` and pinned the substring `probe IN (`, which is
753
+ * QueryHelper.in's chosen spelling — in a file this suite does not own.
754
+ * Rendering the same set membership as `= ANY(:x)` would have reddened
755
+ * this test with the invariant perfectly intact, which is precisely the
756
+ * kind of coupling that made this test red once already.
757
+ */
758
+ expect(boundIdsOf(nestedIdFilter as FindOperator<unknown>)).toEqual([
759
+ permittedLabelId.toString(),
760
+ ]);
532
761
  });
533
762
 
534
763
  it("runs the query through ModelPermission.checkReadQueryPermission, like findBy and countBy", () => {
@@ -543,24 +772,30 @@ describe("the contract aggregateBy's own comments claim", () => {
543
772
  expect(AGGREGATE_BY_BODY).toContain("checkReadPermissionType.query");
544
773
  });
545
774
 
546
- it("validates orderBy expressions too", () => {
775
+ it("validates orderBy expressions above the permission call, which is what makes them testable", () => {
547
776
  /*
548
- * The one guard this suite cannot exercise, because it sits after
549
- * buildAggregateScope() which calls getQueryBuilder() and so needs a
550
- * live connection to reach (see the note in the statement-separator
551
- * block). Deleting the call is the regression; that much is visible from
552
- * here.
777
+ * The black-box test in the statement-separator block calls aggregateBy
778
+ * with a semicolon in an orderBy expression and expects BadDataException.
779
+ * It only reaches that check because the orderBy validation loop sits
780
+ * ABOVE checkReadQueryPermission and above buildAggregateScope, which is
781
+ * what keeps this suite database-free. Move the loop back down beside
782
+ * addOrderBy and that test starts failing on a missing connection instead
783
+ * — a confusing failure about jest rather than about the guard — so the
784
+ * position is pinned here, where the message is about the position.
553
785
  */
554
- const orderByLoopStart: number = AGGREGATE_BY_BODY.indexOf(
555
- "aggregateBy.orderBy",
556
- );
557
- const orderByLoopEnd: number = AGGREGATE_BY_BODY.indexOf("addOrderBy");
558
-
559
- expect(orderByLoopStart).toBeGreaterThan(-1);
560
- expect(orderByLoopEnd).toBeGreaterThan(orderByLoopStart);
561
- expect(AGGREGATE_BY_BODY.slice(orderByLoopStart, orderByLoopEnd)).toContain(
786
+ const orderByValidationAt: number = AGGREGATE_BY_BODY.indexOf(
562
787
  "assertSafeAggregateExpression(order.expression)",
563
788
  );
789
+ const permissionAt: number = AGGREGATE_BY_BODY.indexOf(
790
+ "checkReadQueryPermission",
791
+ );
792
+ const applyLoopAt: number = AGGREGATE_BY_BODY.indexOf("addOrderBy");
793
+
794
+ expect(orderByValidationAt).toBeGreaterThan(-1);
795
+ expect(permissionAt).toBeGreaterThan(-1);
796
+ expect(applyLoopAt).toBeGreaterThan(-1);
797
+ expect(orderByValidationAt).toBeLessThan(permissionAt);
798
+ expect(applyLoopAt).toBeGreaterThan(permissionAt);
564
799
  });
565
800
 
566
801
  it("validates aliases and expressions before it touches permissions or the database", () => {
@@ -0,0 +1,151 @@
1
+ import DatabaseService from "../../../Server/Services/DatabaseService";
2
+ import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
3
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
4
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
5
+
6
+ /*
7
+ * Every paged read here is LIMIT/OFFSET, and OFFSET only means anything
8
+ * against an ordering with no ties. Rows that compare equal may come back in
9
+ * any order, and nothing obliges Postgres to pick the same order for the query
10
+ * that fetches page 1 and the query that fetches page 2 — so a tied row can be
11
+ * served on both pages while another is served on neither. The user sees one
12
+ * item listed twice.
13
+ *
14
+ * The Inventory list is where this bites hardest, and it is why it belongs in
15
+ * the duplicate-items fix: it sorts on `lastSeenAt`, which the entity
16
+ * reconciler rewrites for every live entity every few minutes, so the sort key
17
+ * is being mutated between the two fetches by design rather than by bad luck.
18
+ * The inventory mirror also stamps one identical `lastSeenAt` across a whole
19
+ * page of rows at a time, which produces large exact-tie groups outright.
20
+ *
21
+ * The fix is to complete whatever sort the caller asked for into a total order
22
+ * by appending the primary key, in the one place every paged read goes
23
+ * through.
24
+ */
25
+
26
+ type FindArgs = {
27
+ order?: Record<string, unknown> | undefined;
28
+ select?: Record<string, unknown> | undefined;
29
+ };
30
+
31
+ describe("DatabaseService._findBy completes a partial sort into a total order", () => {
32
+ let service: DatabaseService<InventoryItem>;
33
+ let find: jest.Mock;
34
+
35
+ type FindByFunction = (
36
+ sort?: Record<string, unknown> | undefined,
37
+ ) => Promise<Array<InventoryItem>>;
38
+
39
+ const findBy: FindByFunction = (
40
+ sort?: Record<string, unknown> | undefined,
41
+ ): Promise<Array<InventoryItem>> => {
42
+ return service.findBy({
43
+ query: {},
44
+ limit: 10,
45
+ skip: 0,
46
+ select: { displayName: true } as any,
47
+ ...(sort ? { sort: sort as any } : {}),
48
+ props: { isRoot: true },
49
+ });
50
+ };
51
+
52
+ const orderPassedToDatabase: () => Record<string, unknown> = (): Record<
53
+ string,
54
+ unknown
55
+ > => {
56
+ return (find.mock.calls[0]![0] as FindArgs).order || {};
57
+ };
58
+
59
+ beforeEach(() => {
60
+ jest.restoreAllMocks();
61
+
62
+ service = new DatabaseService<InventoryItem>(InventoryItem);
63
+
64
+ find = jest
65
+ .fn()
66
+ .mockImplementation(async (): Promise<Array<InventoryItem>> => {
67
+ return [];
68
+ });
69
+
70
+ jest.spyOn(service, "getRepository").mockReturnValue({ find: find } as any);
71
+ });
72
+
73
+ afterEach(() => {
74
+ jest.restoreAllMocks();
75
+ });
76
+
77
+ it("appends the primary key to a single-column sort", async () => {
78
+ await findBy({ lastSeenAt: SortOrder.Descending });
79
+
80
+ expect(orderPassedToDatabase()).toEqual({
81
+ lastSeenAt: SortOrder.Descending,
82
+ _id: SortOrder.Ascending,
83
+ });
84
+ });
85
+
86
+ it("keeps the caller's column first so the requested ordering is unchanged", async () => {
87
+ await findBy({ lastSeenAt: SortOrder.Descending });
88
+
89
+ expect(Object.keys(orderPassedToDatabase())).toEqual(["lastSeenAt", "_id"]);
90
+ });
91
+
92
+ it("appends the primary key to a multi-column sort", async () => {
93
+ await findBy({
94
+ entityType: SortOrder.Ascending,
95
+ lastSeenAt: SortOrder.Descending,
96
+ });
97
+
98
+ expect(Object.keys(orderPassedToDatabase())).toEqual([
99
+ "entityType",
100
+ "lastSeenAt",
101
+ "_id",
102
+ ]);
103
+ });
104
+
105
+ it("appends the primary key to the default sort as well", async () => {
106
+ await findBy(undefined);
107
+
108
+ expect(orderPassedToDatabase()).toEqual({
109
+ createdAt: SortOrder.Descending,
110
+ _id: SortOrder.Ascending,
111
+ });
112
+ });
113
+
114
+ it("leaves an explicit primary-key sort alone", async () => {
115
+ await findBy({ _id: SortOrder.Descending });
116
+
117
+ expect(orderPassedToDatabase()).toEqual({ _id: SortOrder.Descending });
118
+ });
119
+
120
+ it("does not add the primary key twice when it is already the tiebreaker", async () => {
121
+ await findBy({
122
+ lastSeenAt: SortOrder.Descending,
123
+ _id: SortOrder.Descending,
124
+ });
125
+
126
+ expect(orderPassedToDatabase()).toEqual({
127
+ lastSeenAt: SortOrder.Descending,
128
+ _id: SortOrder.Descending,
129
+ });
130
+ });
131
+
132
+ it("does not widen the projection — the primary key is already selected", async () => {
133
+ await findBy({ lastSeenAt: SortOrder.Descending });
134
+
135
+ const select: Record<string, unknown> = (find.mock.calls[0]![0] as FindArgs)
136
+ .select as Record<string, unknown>;
137
+
138
+ expect(select["_id"]).toBe(true);
139
+ expect(select["displayName"]).toBe(true);
140
+ });
141
+
142
+ it("does not mutate the caller's own sort object", async () => {
143
+ const callerSort: Record<string, unknown> = {
144
+ lastSeenAt: SortOrder.Descending,
145
+ };
146
+
147
+ await findBy(callerSort);
148
+
149
+ expect(callerSort).toEqual({ lastSeenAt: SortOrder.Descending });
150
+ });
151
+ });
@@ -246,3 +246,108 @@ describe("machine creates are left alone", () => {
246
246
  expect(createBy.data.source).toBe(EntitySource.Inventory);
247
247
  });
248
248
  });
249
+
250
+ /*
251
+ * The escape hatch above is for the two machine writers, which reach the
252
+ * service directly with `props: { isRoot: true }`. It used to be keyed on the
253
+ * PAYLOAD instead — "did the caller send an entityKey" — which let anyone
254
+ * posting to the public CRUD endpoint skip every check in the hook simply by
255
+ * inventing one, and claim a source only ingest is supposed to write. A row
256
+ * that says `discovered` is a row the prune sweep is entitled to delete on a
257
+ * TTL, so this was not only a lie in the Source column.
258
+ */
259
+ describe("non-root creates cannot impersonate a machine writer", () => {
260
+ function apiCreate(data: InventoryItem): CreateBy<InventoryItem> {
261
+ return { data, props: {} } as CreateBy<InventoryItem>;
262
+ }
263
+
264
+ function externalService(): InventoryItem {
265
+ const data: InventoryItem = new InventoryItem();
266
+ data.projectId = PROJECT_ID;
267
+ data.entityType = EntityType.ExternalService;
268
+ data.displayName = "Stripe Payments API";
269
+ return data;
270
+ }
271
+
272
+ test("a client-supplied entity key is discarded and the key re-derived", async () => {
273
+ const data: InventoryItem = externalService();
274
+ data.entityKey = "deadbeefdeadbeef";
275
+
276
+ const createBy: CreateBy<InventoryItem> = apiCreate(data);
277
+ await callOnBeforeCreate(createBy);
278
+
279
+ expect(createBy.data.entityKey).toBe(
280
+ keyForManualEntity(
281
+ PROJECT_ID.toString(),
282
+ EntityType.ExternalService,
283
+ "Stripe Payments API",
284
+ ),
285
+ );
286
+ expect(createBy.data.source).toBe(EntitySource.Manual);
287
+ });
288
+
289
+ test("a client-supplied source of discovered is overridden to manual", async () => {
290
+ const data: InventoryItem = externalService();
291
+ data.entityKey = "deadbeefdeadbeef";
292
+ data.source = EntitySource.Discovered;
293
+
294
+ const createBy: CreateBy<InventoryItem> = apiCreate(data);
295
+ await callOnBeforeCreate(createBy);
296
+
297
+ expect(createBy.data.source).toBe(EntitySource.Manual);
298
+ });
299
+
300
+ test("a client-supplied source of inventory is overridden to manual", async () => {
301
+ const data: InventoryItem = externalService();
302
+ data.entityKey = "deadbeefdeadbeef";
303
+ data.source = EntitySource.Inventory;
304
+
305
+ const createBy: CreateBy<InventoryItem> = apiCreate(data);
306
+ await callOnBeforeCreate(createBy);
307
+
308
+ expect(createBy.data.source).toBe(EntitySource.Manual);
309
+ });
310
+
311
+ test.each([
312
+ EntityType.Service,
313
+ EntityType.Host,
314
+ EntityType.KubernetesPod,
315
+ EntityType.KubernetesNode,
316
+ EntityType.NetworkDevice,
317
+ ])(
318
+ "the manual-type restriction still applies to %s when a key is supplied",
319
+ async (entityType: EntityType) => {
320
+ const data: InventoryItem = externalService();
321
+ data.entityType = entityType;
322
+ data.entityKey = "deadbeefdeadbeef";
323
+ data.source = EntitySource.Discovered;
324
+
325
+ await expect(callOnBeforeCreate(apiCreate(data))).rejects.toThrow(
326
+ /discovered automatically and cannot be created manually/,
327
+ );
328
+ },
329
+ );
330
+
331
+ test("the display-name requirement still applies when a key is supplied", async () => {
332
+ const data: InventoryItem = externalService();
333
+ data.displayName = " ";
334
+ data.entityKey = "deadbeefdeadbeef";
335
+ data.source = EntitySource.Discovered;
336
+
337
+ await expect(callOnBeforeCreate(apiCreate(data))).rejects.toThrow(
338
+ /Name is required/,
339
+ );
340
+ });
341
+
342
+ test("firstSeenAt and lastSeenAt are stamped rather than left to the caller", async () => {
343
+ const data: InventoryItem = externalService();
344
+ data.entityKey = "deadbeefdeadbeef";
345
+ data.source = EntitySource.Discovered;
346
+
347
+ const createBy: CreateBy<InventoryItem> = apiCreate(data);
348
+ await callOnBeforeCreate(createBy);
349
+
350
+ expect(createBy.data.firstSeenAt).toBeInstanceOf(Date);
351
+ expect(createBy.data.lastSeenAt).toBeInstanceOf(Date);
352
+ });
353
+ });