@oneuptime/common 14.0.8 → 14.0.10

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 (976) hide show
  1. package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
  2. package/Models/DatabaseModels/CephCluster.ts +213 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +213 -0
  4. package/Models/DatabaseModels/DockerHost.ts +213 -0
  5. package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
  6. package/Models/DatabaseModels/GlobalConfig.ts +1 -1
  7. package/Models/DatabaseModels/Host.ts +213 -0
  8. package/Models/DatabaseModels/Index.ts +4 -0
  9. package/Models/DatabaseModels/KubernetesAiAgent.ts +364 -0
  10. package/Models/DatabaseModels/KubernetesCluster.ts +3 -3
  11. package/Models/DatabaseModels/PodmanHost.ts +213 -0
  12. package/Models/DatabaseModels/Project.ts +55 -5
  13. package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
  14. package/Models/DatabaseModels/ProxmoxResource.ts +49 -1
  15. package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
  16. package/Models/DatabaseModels/RunnerJob.ts +219 -0
  17. package/Models/DatabaseModels/TeamComplianceSetting.ts +143 -2
  18. package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
  19. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +17 -0
  20. package/Server/API/AIChatAPI.ts +6 -3
  21. package/Server/API/AIInvestigationAPI.ts +17 -10
  22. package/Server/API/AutoRemediationAPI.ts +598 -25
  23. package/Server/API/KubernetesClusterAiAccessAPI.ts +623 -21
  24. package/Server/API/MicrosoftTeamsAPI.ts +93 -14
  25. package/Server/API/ProxmoxResourceAPI.ts +244 -14
  26. package/Server/API/ResourceAiAccessAPI.ts +1669 -0
  27. package/Server/Infrastructure/GlobalCache.ts +32 -0
  28. package/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.ts +99 -0
  29. package/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.ts +137 -0
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.ts +42 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  33. package/Server/Infrastructure/Semaphore.ts +22 -0
  34. package/Server/Middleware/TelemetryIngest.ts +51 -18
  35. package/Server/Services/AIService.ts +70 -1
  36. package/Server/Services/AlertService.ts +352 -11
  37. package/Server/Services/AlertSeverityService.ts +65 -15
  38. package/Server/Services/AnalyticsDatabaseService.ts +45 -1
  39. package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
  40. package/Server/Services/AutoRemediationRuleService.ts +1 -1
  41. package/Server/Services/CephClusterService.ts +109 -1
  42. package/Server/Services/DatabaseServerService.ts +116 -1
  43. package/Server/Services/DockerHostService.ts +109 -1
  44. package/Server/Services/DockerSwarmClusterService.ts +113 -1
  45. package/Server/Services/HostService.ts +126 -4
  46. package/Server/Services/IncidentService.ts +280 -104
  47. package/Server/Services/IncidentSeverityService.ts +66 -15
  48. package/Server/Services/KubernetesAiAgentJobService.ts +185 -0
  49. package/Server/Services/KubernetesAiAgentService.ts +1634 -0
  50. package/Server/Services/KubernetesClusterAiAccessService.ts +1118 -451
  51. package/Server/Services/KubernetesClusterService.ts +129 -21
  52. package/Server/Services/MetricRecordingRuleService.ts +47 -0
  53. package/Server/Services/PodmanHostService.ts +109 -1
  54. package/Server/Services/ProjectService.ts +32 -0
  55. package/Server/Services/ProxmoxClusterService.ts +110 -1
  56. package/Server/Services/ProxmoxResourceService.ts +322 -5
  57. package/Server/Services/ResourceAiAccessService.ts +1439 -0
  58. package/Server/Services/ResourceAiAgentJobService.ts +202 -0
  59. package/Server/Services/ResourceAiAgentService.ts +2432 -0
  60. package/Server/Services/RunnerJobService.ts +852 -108
  61. package/Server/Services/RunnerService.ts +1 -1
  62. package/Server/Services/ScheduledMaintenanceService.ts +737 -1
  63. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +414 -56
  64. package/Server/Services/TeamComplianceSettingService.ts +1125 -9
  65. package/Server/Services/TelemetryUsageBillingService.ts +28 -0
  66. package/Server/Services/TraceRecordingRuleService.ts +33 -0
  67. package/Server/Services/VMwareVCenterService.ts +110 -1
  68. package/Server/Utils/AI/ClusterAccess/ClusterAccessContext.ts +67 -9
  69. package/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.ts +39 -10
  70. package/Server/Utils/AI/ClusterAccess/KubectlJobRunner.ts +14 -3
  71. package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1475 -51
  72. package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +1000 -43
  73. package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
  74. package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
  75. package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
  76. package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
  77. package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
  78. package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
  79. package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
  80. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +120 -7
  81. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
  82. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
  83. package/Server/Utils/AI/SRE/IncidentPostmortemRunner.ts +40 -8
  84. package/Server/Utils/AI/SRE/InvestigationEligibility.ts +13 -2
  85. package/Server/Utils/AI/SRE/InvestigationGrader.ts +1 -1
  86. package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +402 -14
  87. package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
  88. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
  89. package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
  90. package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
  91. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +133 -7
  92. package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +58 -4
  93. package/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.ts +447 -0
  94. package/Server/Utils/Telemetry/ProxmoxNativePush.ts +903 -0
  95. package/Server/Utils/Telemetry/TelemetryEntity.ts +87 -27
  96. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
  97. package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
  98. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
  99. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
  100. package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
  101. package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
  102. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +1073 -213
  103. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
  104. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
  105. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
  106. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
  107. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
  108. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
  109. package/Tests/App/AdminDashboard/RowActionPlacementOverrides.test.tsx +278 -0
  110. package/Tests/App/Dashboard/AIChatChartWidgetZoom.test.tsx +738 -0
  111. package/Tests/App/Dashboard/AIFeaturesSettingsPointers.test.tsx +147 -0
  112. package/Tests/App/Dashboard/AIInsightsKubernetesClusterPage.test.tsx +1523 -0
  113. package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +463 -3
  114. package/Tests/App/Dashboard/AffectedResourcesPickerDropdownPlacement.test.tsx +958 -0
  115. package/Tests/App/Dashboard/AffectedResourcesPickerFocusLeave.test.tsx +595 -0
  116. package/Tests/App/Dashboard/AskAiAvailability.test.ts +20 -3
  117. package/Tests/App/Dashboard/AskAiDisabled.test.tsx +8 -3
  118. package/Tests/App/Dashboard/CephRateChartReload.test.tsx +1456 -0
  119. package/Tests/App/Dashboard/CephTimeRangeZoom.test.tsx +1229 -0
  120. package/Tests/App/Dashboard/ChartCardTimeRangeZoom.test.tsx +151 -0
  121. package/Tests/App/Dashboard/ChartCardZoomAffordances.test.tsx +257 -0
  122. package/Tests/App/Dashboard/ChartRefetchFrame.test.tsx +141 -0
  123. package/Tests/App/Dashboard/ChartZoomHarness.tsx +249 -0
  124. package/Tests/App/Dashboard/ChartZoomStandIn.tsx +131 -0
  125. package/Tests/App/Dashboard/CloudServerlessSlowLoads.test.tsx +628 -0
  126. package/Tests/App/Dashboard/CloudServerlessTimeRangeZoom.test.tsx +607 -0
  127. package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +184 -19
  128. package/Tests/App/Dashboard/CompanionMetricsTabZoom.test.tsx +632 -0
  129. package/Tests/App/Dashboard/ContainerChartZoomStandIn.tsx +195 -0
  130. package/Tests/App/Dashboard/ContainerHostOverviewSlowLoads.test.tsx +542 -0
  131. package/Tests/App/Dashboard/ContainerHostOverviewTimeRangeZoom.test.tsx +848 -0
  132. package/Tests/App/Dashboard/ContainerMetricsTimeRangeZoom.test.tsx +561 -0
  133. package/Tests/App/Dashboard/DashboardBoardWideZoomShell.test.tsx +1147 -0
  134. package/Tests/App/Dashboard/DashboardEditPermissions.test.tsx +3 -2
  135. package/Tests/App/Dashboard/DashboardHistogramWidgetDragCursor.test.tsx +399 -0
  136. package/Tests/App/Dashboard/DashboardHistogramWidgetZoom.test.tsx +985 -0
  137. package/Tests/App/Dashboard/DashboardHistogramWidgetZoomHint.test.tsx +803 -0
  138. package/Tests/App/Dashboard/DashboardLazyPages.test.tsx +1 -0
  139. package/Tests/App/Dashboard/DashboardSettingsPreviewZoom.test.tsx +679 -0
  140. package/Tests/App/Dashboard/DashboardSloAndDataSourceChartZoom.test.tsx +881 -0
  141. package/Tests/App/Dashboard/DashboardTraceStatusWidgets.test.tsx +878 -0
  142. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
  143. package/Tests/App/Dashboard/DatabaseMetricChartModalTimeRangeZoom.test.tsx +442 -0
  144. package/Tests/App/Dashboard/DatabaseOverviewTimeRangeZoom.test.tsx +831 -0
  145. package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +43 -0
  146. package/Tests/App/Dashboard/DockerSwarmInsightsTimeRangeZoom.test.tsx +524 -0
  147. package/Tests/App/Dashboard/DockerSwarmRateChartRefresh.test.tsx +259 -0
  148. package/Tests/App/Dashboard/EdgeDetailPanelHistory.test.tsx +763 -0
  149. package/Tests/App/Dashboard/EdgeDetailPanelTimeRangeZoom.test.tsx +547 -0
  150. package/Tests/App/Dashboard/EdgeHistoryBucketsCases.tsx +565 -0
  151. package/Tests/App/Dashboard/EdgeHistoryBucketsKathmandu.test.tsx +73 -0
  152. package/Tests/App/Dashboard/EdgeHistoryBucketsKolkata.test.tsx +74 -0
  153. package/Tests/App/Dashboard/EdgeHistoryBucketsNewYork.test.tsx +72 -0
  154. package/Tests/App/Dashboard/EdgeHistoryBucketsUtc.test.tsx +72 -0
  155. package/Tests/App/Dashboard/EmbeddedMetricCardRefresh.test.tsx +310 -0
  156. package/Tests/App/Dashboard/EmbeddedMetricCardTimeRangeZoom.test.tsx +536 -0
  157. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +3 -1
  158. package/Tests/App/Dashboard/EscalationRuleRowActions.test.tsx +660 -0
  159. package/Tests/App/Dashboard/EventNotes.test.tsx +2 -2
  160. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +587 -13
  161. package/Tests/App/Dashboard/EventOverviewSnapshotExplorerZoom.test.tsx +1139 -0
  162. package/Tests/App/Dashboard/EventOverviewSnapshotZoomAcrossTabs.test.tsx +1057 -0
  163. package/Tests/App/Dashboard/EventOverviewTelemetrySnapshotZoom.test.tsx +757 -0
  164. package/Tests/App/Dashboard/ExceptionOccurrenceTrendZoom.test.tsx +675 -0
  165. package/Tests/App/Dashboard/ExceptionOccurrencesZoomSurvivesViewSwitch.test.tsx +405 -0
  166. package/Tests/App/Dashboard/ExceptionSignalPages.test.tsx +67 -3
  167. package/Tests/App/Dashboard/ExceptionTablesSpanStatus.test.tsx +515 -0
  168. package/Tests/App/Dashboard/ExplorerOwnZoomRegression.test.tsx +739 -0
  169. package/Tests/App/Dashboard/FlowBandwidthAxisCases.tsx +461 -0
  170. package/Tests/App/Dashboard/FlowBandwidthAxisKathmandu.test.tsx +25 -0
  171. package/Tests/App/Dashboard/FlowBandwidthAxisKolkata.test.tsx +25 -0
  172. package/Tests/App/Dashboard/FlowBandwidthAxisNewYork.test.tsx +25 -0
  173. package/Tests/App/Dashboard/FlowBandwidthAxisUtc.test.tsx +24 -0
  174. package/Tests/App/Dashboard/FlowTopTalkersZoomRefetch.test.tsx +722 -0
  175. package/Tests/App/Dashboard/FlowViewportGuard.test.tsx +2526 -0
  176. package/Tests/App/Dashboard/HostPagesSlowLoads.test.tsx +872 -0
  177. package/Tests/App/Dashboard/HostPagesTimeRangeZoom.test.tsx +1208 -0
  178. package/Tests/App/Dashboard/IncidentRootCauseTimeRangeZoom.test.tsx +358 -0
  179. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +100 -9
  180. package/Tests/App/Dashboard/InfrastructureMetricsTabTimeRangeZoom.test.tsx +485 -0
  181. package/Tests/App/Dashboard/InvestigationDrawerCompanionZoom.test.tsx +655 -0
  182. package/Tests/App/Dashboard/InvestigationDrawerZoom.test.tsx +830 -0
  183. package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
  184. package/Tests/App/Dashboard/InvestigationNotStarted.test.tsx +338 -2
  185. package/Tests/App/Dashboard/InvoicesPayInvoice.test.tsx +454 -16
  186. package/Tests/App/Dashboard/KubernetesClusterAiAccessSettings.test.ts +799 -819
  187. package/Tests/App/Dashboard/KubernetesClusterAiAgentOverviewCard.test.tsx +367 -0
  188. package/Tests/App/Dashboard/KubernetesClusterAiAgentStatus.test.ts +1202 -0
  189. package/Tests/App/Dashboard/KubernetesClusterAiDocumentationMarkdown.test.ts +106 -0
  190. package/Tests/App/Dashboard/KubernetesClusterAiNavigation.test.tsx +354 -0
  191. package/Tests/App/Dashboard/KubernetesClusterAiPage.test.tsx +1884 -2054
  192. package/Tests/App/Dashboard/KubernetesClusterAiPageServerParity.test.ts +478 -179
  193. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +50 -0
  194. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +2 -1
  195. package/Tests/App/Dashboard/KubernetesClusterOverviewZoom.test.tsx +1217 -0
  196. package/Tests/App/Dashboard/KubernetesClusterPagesZoom.test.tsx +731 -0
  197. package/Tests/App/Dashboard/KubernetesCostsLoadRecovery.test.tsx +869 -0
  198. package/Tests/App/Dashboard/KubernetesCostsZoom.test.tsx +1214 -0
  199. package/Tests/App/Dashboard/KubernetesCostsZoomExactHours.test.tsx +692 -0
  200. package/Tests/App/Dashboard/KubernetesNetworkThroughputReload.test.tsx +902 -0
  201. package/Tests/App/Dashboard/KubernetesNetworkZoomBucketEdges.test.tsx +571 -0
  202. package/Tests/App/Dashboard/KubernetesOverviewZoomBucketEdges.test.tsx +697 -0
  203. package/Tests/App/Dashboard/KubernetesResourceMetricsTabZoom.test.tsx +633 -0
  204. package/Tests/App/Dashboard/LogsExplorerTimeRangeZoom.test.tsx +470 -0
  205. package/Tests/App/Dashboard/LogsInsightsTimeRangeZoom.test.tsx +948 -0
  206. package/Tests/App/Dashboard/LogsViewerPinnedWindowZoom.test.tsx +486 -0
  207. package/Tests/App/Dashboard/MetricChartsBucketInspectorDoubleClick.test.tsx +1001 -0
  208. package/Tests/App/Dashboard/MetricExplorerInvestigationAutoRefresh.test.tsx +514 -0
  209. package/Tests/App/Dashboard/MetricExplorerSavedViewZoomRoundTrip.test.tsx +581 -0
  210. package/Tests/App/Dashboard/MetricExplorerTimeRangeZoom.test.tsx +1162 -0
  211. package/Tests/App/Dashboard/MetricMonitorPreviewZoomLabel.test.ts +72 -0
  212. package/Tests/App/Dashboard/MetricRowSparklineTimeRangeZoom.test.tsx +204 -0
  213. package/Tests/App/Dashboard/MetricViewFirstResultLands.test.tsx +408 -0
  214. package/Tests/App/Dashboard/MetricViewOwnZoomResetPlacement.test.tsx +952 -0
  215. package/Tests/App/Dashboard/MetricViewTimeRange.test.ts +139 -0
  216. package/Tests/App/Dashboard/MetricViewTimeRangeZoom.test.tsx +556 -0
  217. package/Tests/App/Dashboard/MonitorMetricsChartWrappersZoom.test.tsx +552 -0
  218. package/Tests/App/Dashboard/MonitorMetricsTabsTimeRangeZoom.test.tsx +740 -0
  219. package/Tests/App/Dashboard/MonitorOverviewChartsTimeRangeZoom.test.tsx +735 -0
  220. package/Tests/App/Dashboard/MonitorStepPreviewsTimeRangeZoom.test.tsx +835 -0
  221. package/Tests/App/Dashboard/NetworkDeviceTimeRangeZoom.test.tsx +1115 -0
  222. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +186 -17
  223. package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +155 -46
  224. package/Tests/App/Dashboard/PasskeySettings.test.tsx +87 -2
  225. package/Tests/App/Dashboard/ProjectAISettingsPages.test.tsx +884 -0
  226. package/Tests/App/Dashboard/ProxmoxNodeRemove.test.tsx +621 -0
  227. package/Tests/App/Dashboard/ProxmoxOverviewSlowLoads.test.tsx +584 -0
  228. package/Tests/App/Dashboard/ProxmoxPagesTimeRangeZoom.test.tsx +823 -0
  229. package/Tests/App/Dashboard/ProxmoxRateChartReload.test.tsx +1253 -0
  230. package/Tests/App/Dashboard/PublicDashboardBoardWideZoom.test.tsx +605 -0
  231. package/Tests/App/Dashboard/RealRowsLineChart.tsx +250 -0
  232. package/Tests/App/Dashboard/RechartsHostsPlotCursor.test.tsx +513 -0
  233. package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
  234. package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
  235. package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
  236. package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
  237. package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
  238. package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
  239. package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
  240. package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
  241. package/Tests/App/Dashboard/RowActionPlacementOverrides.test.tsx +865 -0
  242. package/Tests/App/Dashboard/RumOverviewTimeRangeZoom.test.tsx +877 -0
  243. package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
  244. package/Tests/App/Dashboard/RunnerKubernetesAgentForm.test.tsx +20 -4
  245. package/Tests/App/Dashboard/RunnerSupersededByAiAgentNote.test.tsx +296 -0
  246. package/Tests/App/Dashboard/SecurityEventConnectionsTable.test.tsx +146 -36
  247. package/Tests/App/Dashboard/ServiceDatabaseSlowLoads.test.tsx +1166 -0
  248. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +1243 -104
  249. package/Tests/App/Dashboard/ServiceOverviewTimeRangeZoom.test.tsx +792 -0
  250. package/Tests/App/Dashboard/SloBudgetBurnDownZoom.test.tsx +771 -0
  251. package/Tests/App/Dashboard/SloBudgetBurnDownZoomAffordances.test.tsx +380 -0
  252. package/Tests/App/Dashboard/SloEventMetricsBarChartZoom.test.tsx +369 -0
  253. package/Tests/App/Dashboard/SloHistoryChartsTimeRangeZoom.test.tsx +694 -0
  254. package/Tests/App/Dashboard/SloHistoryChartsZoomAffordances.test.tsx +497 -0
  255. package/Tests/App/Dashboard/SloMetricsTimeRangeZoom.test.tsx +541 -0
  256. package/Tests/App/Dashboard/SlowLoadHarness.ts +123 -0
  257. package/Tests/App/Dashboard/SnapshotExplorerPrimaryZoom.test.tsx +1194 -0
  258. package/Tests/App/Dashboard/SnapshotViewerPinnedWindow.test.tsx +620 -0
  259. package/Tests/App/Dashboard/TelemetrySnapshotPanelTimeRangeZoom.test.tsx +765 -0
  260. package/Tests/App/Dashboard/TelemetrySnapshotZoom.test.tsx +516 -0
  261. package/Tests/App/Dashboard/TelemetrySnapshotZoomAcrossTabs.test.tsx +827 -0
  262. package/Tests/App/Dashboard/TimeRangeZoomPageHarness.tsx +463 -0
  263. package/Tests/App/Dashboard/TraceChartEditorStatusSplit.test.tsx +543 -0
  264. package/Tests/App/Dashboard/TraceStatusPresentation.test.tsx +967 -0
  265. package/Tests/App/Dashboard/TraceTimelineMinimapZoom.test.tsx +315 -0
  266. package/Tests/App/Dashboard/TracesAnalyticsStatusSplit.test.tsx +778 -0
  267. package/Tests/App/Dashboard/TracesAnalyticsViewZoom.test.tsx +349 -0
  268. package/Tests/App/Dashboard/TracesExplorerTimeRangeZoom.test.tsx +551 -0
  269. package/Tests/App/Dashboard/TracesStatusChartAndFacet.test.tsx +687 -0
  270. package/Tests/App/Dashboard/TracesViewerStaleResponses.test.tsx +458 -0
  271. package/Tests/App/Dashboard/UptimeStripsExcludedFromZoom.test.tsx +406 -0
  272. package/Tests/App/Dashboard/VMwareOverviewSlowLoads.test.tsx +535 -0
  273. package/Tests/App/Dashboard/VMwarePagesTimeRangeZoom.test.tsx +606 -0
  274. package/Tests/App/Dashboard/ValueWidgetSparklineStaleHover.test.tsx +1292 -0
  275. package/Tests/App/Dashboard/ValueWidgetSparklineZoom.test.tsx +765 -0
  276. package/Tests/App/StatusPage/StatusPageResourceExplorer.test.tsx +98 -0
  277. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
  278. package/Tests/Models/DatabaseModels/KubernetesAiAgentModel.test.ts +295 -0
  279. package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
  280. package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
  281. package/Tests/Models/DatabaseModels/TeamComplianceSettingModel.test.ts +347 -0
  282. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +58 -0
  283. package/Tests/Server/API/AIGenerationProjectAIToggle.test.ts +1 -1
  284. package/Tests/Server/API/AIInvestigationAPI.test.ts +123 -45
  285. package/Tests/Server/API/AutoRemediationApproveClusterRound.test.ts +1329 -0
  286. package/Tests/Server/API/AutoRemediationApproveKubectlRecheck.test.ts +25 -2
  287. package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
  288. package/Tests/Server/API/KubernetesClusterAiAccessAPI.test.ts +2 -0
  289. package/Tests/Server/API/KubernetesClusterAiAccessAPIAgent.test.ts +903 -0
  290. package/Tests/Server/API/MicrosoftTeamsChatsRefreshRoute.test.ts +667 -0
  291. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +294 -6
  292. package/Tests/Server/API/ProxmoxResourceAPIRemoveNode.test.ts +1932 -0
  293. package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
  294. package/Tests/Server/Infrastructure/GlobalCache.test.ts +195 -0
  295. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
  296. package/Tests/Server/Infrastructure/Postgres/AddKubernetesAiAgentAndAiDefaultsMigration.test.ts +623 -0
  297. package/Tests/Server/Infrastructure/Postgres/AddKubernetesClusterAiAccessMigration.test.ts +87 -10
  298. package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
  299. package/Tests/Server/Infrastructure/Postgres/AddTeamComplianceRuleScopeMigration.test.ts +622 -0
  300. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
  301. package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +119 -4
  302. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +196 -2
  303. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +123 -2
  304. package/Tests/Server/Services/AIServiceBalanceBlocker.test.ts +282 -0
  305. package/Tests/Server/Services/AlertMonitorClearWritesNull.test.ts +409 -0
  306. package/Tests/Server/Services/AlertMonitorEditFeed.test.ts +474 -0
  307. package/Tests/Server/Services/AlertMonitorEditGuard.test.ts +610 -0
  308. package/Tests/Server/Services/AutoRemediationClusterRuleEngine.test.ts +2 -0
  309. package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
  310. package/Tests/Server/Services/AutoRemediationRuleCommandRunners.test.ts +2 -1
  311. package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
  312. package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
  313. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
  314. package/Tests/Server/Services/IncidentAffectedMonitorsEdit.test.ts +1360 -0
  315. package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +3 -1
  316. package/Tests/Server/Services/IncidentSeverityDeletePausesComplianceRules.test.ts +488 -0
  317. package/Tests/Server/Services/KubernetesAiAgentJobService.test.ts +329 -0
  318. package/Tests/Server/Services/KubernetesAiAgentServiceHelpers.test.ts +618 -0
  319. package/Tests/Server/Services/KubernetesAiAgentServiceLifecycle.test.ts +1444 -0
  320. package/Tests/Server/Services/KubernetesAiAgentServiceRegister.test.ts +1666 -0
  321. package/Tests/Server/Services/KubernetesClusterAiAccessDeletedRunnerRemedy.test.ts +140 -111
  322. package/Tests/Server/Services/KubernetesClusterAiAccessGapNextSteps.test.ts +267 -105
  323. package/Tests/Server/Services/KubernetesClusterAiAccessLegacySuperseded.test.ts +440 -0
  324. package/Tests/Server/Services/KubernetesClusterAiAccessService.test.ts +567 -411
  325. package/Tests/Server/Services/KubernetesClusterAiAccessTarget.test.ts +1168 -0
  326. package/Tests/Server/Services/KubernetesClusterAiSettingsPermission.test.ts +410 -12
  327. package/Tests/Server/Services/KubernetesClusterServiceAiAccessFeed.test.ts +55 -0
  328. package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
  329. package/Tests/Server/Services/ProjectServiceNewProjectAiDefaults.test.ts +288 -0
  330. package/Tests/Server/Services/ProxmoxResourcePostgres.test.ts +5633 -0
  331. package/Tests/Server/Services/ProxmoxResourceService.test.ts +6458 -5
  332. package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
  333. package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
  334. package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
  335. package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
  336. package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
  337. package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
  338. package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
  339. package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
  340. package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
  341. package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
  342. package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +153 -15
  343. package/Tests/Server/Services/RunnerJobEnqueueKubectlAiAgent.test.ts +620 -0
  344. package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
  345. package/Tests/Server/Services/RunnerJobKubectlWriteScope.test.ts +170 -3
  346. package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
  347. package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
  348. package/Tests/Server/Services/RunnerJobTimeoutReason.test.ts +72 -0
  349. package/Tests/Server/Services/RunnerServiceAgentName.test.ts +6 -4
  350. package/Tests/Server/Services/ScheduledMaintenanceAttachmentEdit.test.ts +2113 -0
  351. package/Tests/Server/Services/ScheduledMaintenanceMonitorRelease.test.ts +972 -0
  352. package/Tests/Server/Services/TeamComplianceSettingPostgres.test.ts +2237 -0
  353. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +3988 -138
  354. package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
  355. package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
  356. package/Tests/Server/Utils/AI/ClusterAccessContext.test.ts +107 -1
  357. package/Tests/Server/Utils/AI/IncidentPostmortemRunner.test.ts +351 -0
  358. package/Tests/Server/Utils/AI/InvestigationClusterAccessWiring.test.ts +2 -0
  359. package/Tests/Server/Utils/AI/InvestigationEligibility.test.ts +310 -0
  360. package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
  361. package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
  362. package/Tests/Server/Utils/AI/KubectlInvestigationToolkit.test.ts +70 -0
  363. package/Tests/Server/Utils/AI/KubectlJobRunnerFinishedJob.test.ts +58 -0
  364. package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
  365. package/Tests/Server/Utils/AI/RemediationCommandToolsKubectl.test.ts +2 -0
  366. package/Tests/Server/Utils/AI/RemediationCommandToolsKubectlOutcome.test.ts +2 -0
  367. package/Tests/Server/Utils/AI/RemediationCommandToolsLiveCluster.test.ts +2 -0
  368. package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
  369. package/Tests/Server/Utils/AI/RemediationCommandToolsRunnerScope.test.ts +125 -1
  370. package/Tests/Server/Utils/AI/RemediationExecutionRunner.test.ts +252 -3
  371. package/Tests/Server/Utils/AI/RemediationExecutionRunnerClusterMode.test.ts +2 -0
  372. package/Tests/Server/Utils/AI/RemediationExecutionRunnerProjectGates.test.ts +240 -0
  373. package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
  374. package/Tests/Server/Utils/AI/RemediationKubectlCopyParity.test.ts +2 -0
  375. package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
  376. package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
  377. package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
  378. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
  379. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
  380. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
  381. package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
  382. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutor.test.ts +2 -0
  383. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
  384. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorUnknownRun.test.ts +2 -0
  385. package/Tests/Server/Utils/AutoRemediation/CommandPlanVerification.test.ts +2 -0
  386. package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
  387. package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
  388. package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
  389. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
  390. package/Tests/Server/Utils/Telemetry/HostAssetAttributesFromOtlp.test.ts +68 -0
  391. package/Tests/Server/Utils/Telemetry/InventoryMirrorPostgres.test.ts +370 -0
  392. package/Tests/Server/Utils/Telemetry/NetworkDeviceInventoryAttributes.test.ts +522 -0
  393. package/Tests/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.test.ts +155 -0
  394. package/Tests/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.test.ts +8631 -0
  395. package/Tests/Server/Utils/Telemetry/ProxmoxNativePush.test.ts +2852 -0
  396. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +367 -1
  397. package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +75 -3
  398. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
  399. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
  400. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
  401. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
  402. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
  403. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
  404. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
  405. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
  406. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
  407. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
  408. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
  409. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
  410. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
  411. package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
  412. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +25 -0
  413. package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
  414. package/Tests/Server/Utils/Workspace/MicrosoftTeamsGroupChatNames.test.ts +3119 -0
  415. package/Tests/Server/Utils/Workspace/MicrosoftTeamsGroupChatNamesPostgres.test.ts +1721 -0
  416. package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
  417. package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
  418. package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
  419. package/Tests/Types/Kubernetes/KubernetesAgentChartAiAccess.test.ts +581 -113
  420. package/Tests/Types/Kubernetes/KubernetesAiAgentAccessContract.test.ts +510 -0
  421. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
  422. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
  423. package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
  424. package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
  425. package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
  426. package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
  427. package/Tests/Types/Team/ComplianceRule.test.ts +1029 -0
  428. package/Tests/Types/Team/TeamComplianceStatus.test.ts +176 -0
  429. package/Tests/UI/Components/ActionButton/RowActions.test.tsx +641 -0
  430. package/Tests/UI/Components/ActionButton/RowActionsAccessibility.test.tsx +414 -0
  431. package/Tests/UI/Components/ActionButton/SplitActionButtons.test.ts +565 -0
  432. package/Tests/UI/Components/Charts/BarChartDragToSelect.test.tsx +386 -0
  433. package/Tests/UI/Components/Charts/ChartDoubleClickResetWithoutDblclick.test.tsx +1025 -0
  434. package/Tests/UI/Components/Charts/ChartGroupPageZoomHint.test.tsx +196 -0
  435. package/Tests/UI/Components/Charts/ChartPlaceholdersTakeDoubleClick.test.tsx +1861 -0
  436. package/Tests/UI/Components/Charts/ChartPlotClicksWhileResetArmed.test.tsx +382 -0
  437. package/Tests/UI/Components/Charts/ChartRangeSelection.test.tsx +704 -0
  438. package/Tests/UI/Components/Charts/ChartRangeSelectionDoubleClickReset.test.tsx +1481 -0
  439. package/Tests/UI/Components/Charts/ChartRangeSelectionRealRecharts.test.tsx +918 -0
  440. package/Tests/UI/Components/Charts/ChartRowBucketStart.test.ts +350 -0
  441. package/Tests/UI/Components/Charts/DoubleClickReset.test.tsx +996 -0
  442. package/Tests/UI/Components/Charts/HistogramDoubleClickResetRealRecharts.test.tsx +965 -0
  443. package/Tests/UI/Components/Charts/HistogramDriftThenRefetch.test.tsx +277 -0
  444. package/Tests/UI/Components/Charts/HistogramRangeSelectionDataChange.test.tsx +1757 -0
  445. package/Tests/UI/Components/Charts/HistogramRangeSelectionRealRecharts.test.tsx +560 -0
  446. package/Tests/UI/Components/Charts/TimeRangeZoom/ChartPageTimeRangeZoom.test.tsx +513 -0
  447. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.test.tsx +845 -0
  448. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.test.tsx +119 -0
  449. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHintTailwind.test.tsx +274 -0
  450. package/Tests/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.test.ts +433 -0
  451. package/Tests/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.test.tsx +863 -0
  452. package/Tests/UI/Components/Charts/TimeRangeZoom/ZoomDocsPromises.test.tsx +1147 -0
  453. package/Tests/UI/Components/DisabledButtonTooltip.test.tsx +14 -3
  454. package/Tests/UI/Components/EntityDropdownEscape.test.tsx +454 -0
  455. package/Tests/UI/Components/EntityDropdownFocusLeave.test.tsx +420 -0
  456. package/Tests/UI/Components/Forms/FormFieldDropdownDisabled.test.tsx +233 -0
  457. package/Tests/UI/Components/Input.test.tsx +80 -1
  458. package/Tests/UI/Components/LogsAnalyticsViewZoom.test.tsx +947 -0
  459. package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +17 -5
  460. package/Tests/UI/Components/LogsViewerAnalyticsZoom.test.tsx +501 -0
  461. package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +1 -1
  462. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +6 -4
  463. package/Tests/UI/Components/ModelTable/BaseModelTablePermissionGating.test.tsx +60 -8
  464. package/Tests/UI/Components/ModelTable/BaseModelTableRowActions.test.tsx +476 -0
  465. package/Tests/UI/Components/ModelTable/ModelTableWrapContent.test.tsx +7 -5
  466. package/Tests/UI/Components/MoreMenuPortal.test.tsx +623 -0
  467. package/Tests/UI/Components/StatusPage/ResourceGridActions.test.tsx +538 -0
  468. package/Tests/UI/Components/StatusPage/ResourceGridSelection.test.tsx +14 -4
  469. package/Tests/UI/Components/TableCellWrapping.test.tsx +3 -3
  470. package/Tests/UI/Components/TableRowActions.test.tsx +472 -0
  471. package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +1 -1
  472. package/Tests/UI/Components/TelemetryViewer/TelemetryViewerHistogramClickZoom.test.tsx +1 -1
  473. package/Tests/UI/Components/TelemetryViewer/TelemetryViewerZoomScope.test.tsx +470 -0
  474. package/Tests/UI/Components/TelemetryViewer/UseViewerTimeRangeZoom.test.tsx +571 -0
  475. package/Tests/UI/Components/TelemetryViewer/ViewerDoubleClickResetUnderDataChange.test.tsx +928 -0
  476. package/Tests/UI/Components/TelemetryViewer/ViewerFollowsEnclosingZoom.test.tsx +1569 -0
  477. package/Tests/UI/Components/Toggle.test.tsx +66 -0
  478. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsChatsCardRefreshNames.test.tsx +1023 -0
  479. package/Tests/UI/Rum/ChunkLoader.test.ts +201 -0
  480. package/Tests/UI/Rum/FidelityNotices.test.ts +54 -0
  481. package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
  482. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +72 -0
  483. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +1009 -9
  484. package/Tests/UI/Rum/ReplayRecordedAssets.test.ts +1069 -0
  485. package/Tests/UI/Rum/ReplayStage.test.tsx +913 -2
  486. package/Tests/UI/Rum/SessionReplayTable.test.tsx +443 -8
  487. package/Tests/UI/Rum/SessionReplayUsersTable.test.tsx +439 -9
  488. package/Tests/UI/Rum/UserFlowTablesRowActions.test.tsx +501 -0
  489. package/Tests/Utils/AiRemediation/KubectlPolicyChartRbacParity.test.ts +47 -19
  490. package/Tests/Utils/AiRemediation/KubectlWriteScopeCallerParity.test.ts +2 -0
  491. package/Tests/Utils/AiRemediation/KubernetesAiAgentPolicyCopyParity.test.ts +396 -0
  492. package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
  493. package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
  494. package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
  495. package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
  496. package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
  497. package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
  498. package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
  499. package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
  500. package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
  501. package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
  502. package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
  503. package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
  504. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
  505. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
  506. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
  507. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
  508. package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
  509. package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
  510. package/Tests/Utils/Dashboard/DashboardTimeRangeZoom.test.ts +104 -1
  511. package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
  512. package/Types/AI/AIRunType.ts +9 -4
  513. package/Types/AI/InvestigationNotStartedReason.ts +6 -0
  514. package/Types/AI/ResourceAiAccessApi.ts +140 -0
  515. package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
  516. package/Types/AutoRemediation/AiRemediationCommandPlan.ts +197 -18
  517. package/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.ts +28 -0
  518. package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +4 -2
  519. package/Types/Kubernetes/KubernetesClusterAiAccess.ts +263 -32
  520. package/Types/Kubernetes/KubernetesClusterAiAccessPermissions.ts +20 -16
  521. package/Types/Kubernetes/KubernetesClusterAiInsights.ts +81 -0
  522. package/Types/Monitor/ProxmoxAlertTemplates.ts +4 -4
  523. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
  524. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
  525. package/Types/Monitor/RumAlertTemplates.ts +351 -4
  526. package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
  527. package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
  528. package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
  529. package/Types/Runbook/RunbookStepType.ts +29 -0
  530. package/Types/Team/ComplianceNotificationChannel.ts +22 -0
  531. package/Types/Team/ComplianceRule.ts +434 -0
  532. package/Types/Team/ComplianceRuleType.ts +21 -0
  533. package/Types/Team/TeamComplianceStatus.ts +81 -0
  534. package/Types/Telemetry/TelemetryIngestSurface.ts +40 -0
  535. package/UI/Components/ActionButton/ActionButtonSchema.ts +21 -0
  536. package/UI/Components/ActionButton/RowActions.tsx +238 -0
  537. package/UI/Components/ActionButton/SplitActionButtons.ts +173 -0
  538. package/UI/Components/Charts/Area/AreaChart.tsx +32 -2
  539. package/UI/Components/Charts/Bar/BarChart.tsx +40 -3
  540. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +37 -11
  541. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +80 -243
  542. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +122 -31
  543. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +95 -244
  544. package/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.ts +214 -0
  545. package/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.ts +32 -0
  546. package/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.ts +599 -0
  547. package/UI/Components/Charts/Line/LineChart.tsx +32 -2
  548. package/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.tsx +70 -0
  549. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.tsx +226 -0
  550. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.tsx +73 -0
  551. package/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.ts +163 -0
  552. package/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.ts +215 -0
  553. package/UI/Components/Charts/Utils/DataPoint.ts +20 -8
  554. package/UI/Components/Charts/Utils/XAxis.ts +75 -0
  555. package/UI/Components/Charts/Utils/useHistogramRangeSelection.ts +493 -59
  556. package/UI/Components/EntityDropdown/EntityDropdown.tsx +107 -11
  557. package/UI/Components/Forms/Fields/FormField.tsx +10 -0
  558. package/UI/Components/Input/Input.tsx +90 -57
  559. package/UI/Components/List/ListRow.tsx +8 -74
  560. package/UI/Components/LogsViewer/LogsViewer.tsx +40 -11
  561. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +22 -6
  562. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +209 -6
  563. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +127 -84
  564. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +3 -0
  565. package/UI/Components/ModelTable/BaseModelTable.tsx +12 -1
  566. package/UI/Components/MoreMenu/MoreMenu.tsx +365 -30
  567. package/UI/Components/MoreMenu/MoreMenuItem.tsx +70 -17
  568. package/UI/Components/OrderedStatesList/Item.tsx +7 -72
  569. package/UI/Components/StatusPage/ResourceGrid.tsx +126 -77
  570. package/UI/Components/Table/TableBody.tsx +8 -2
  571. package/UI/Components/Table/TableRow.tsx +20 -140
  572. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +41 -10
  573. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +47 -9
  574. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +25 -6
  575. package/UI/Components/TelemetryViewer/types.ts +2 -0
  576. package/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.ts +448 -0
  577. package/UI/Components/Toggle/Toggle.tsx +26 -0
  578. package/UI/Components/Tooltip/Tooltip.tsx +8 -1
  579. package/UI/Styles/Theme.css +14 -2
  580. package/Utils/AI/InvestigationReport.ts +121 -15
  581. package/Utils/AiRemediation/CommandPolicy.ts +1 -1
  582. package/Utils/AiRemediation/KubectlPolicy.ts +1 -1
  583. package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
  584. package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
  585. package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
  586. package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
  587. package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
  588. package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
  589. package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
  590. package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
  591. package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
  592. package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
  593. package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
  594. package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
  595. package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
  596. package/Utils/Dashboard/DashboardTimeRangeZoom.ts +46 -2
  597. package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
  598. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
  599. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
  600. package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
  601. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  602. package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
  603. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
  604. package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
  605. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  606. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
  607. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
  608. package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
  609. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  610. package/build/dist/Models/DatabaseModels/Host.js +219 -0
  611. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  612. package/build/dist/Models/DatabaseModels/Index.js +4 -0
  613. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  614. package/build/dist/Models/DatabaseModels/KubernetesAiAgent.js +385 -0
  615. package/build/dist/Models/DatabaseModels/KubernetesAiAgent.js.map +1 -0
  616. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +3 -3
  617. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  618. package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
  619. package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
  620. package/build/dist/Models/DatabaseModels/Project.js +56 -5
  621. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  622. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
  623. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  624. package/build/dist/Models/DatabaseModels/ProxmoxResource.js +51 -1
  625. package/build/dist/Models/DatabaseModels/ProxmoxResource.js.map +1 -1
  626. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
  627. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
  628. package/build/dist/Models/DatabaseModels/RunnerJob.js +219 -0
  629. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  630. package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js +134 -3
  631. package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js.map +1 -1
  632. package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
  633. package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
  634. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  635. package/build/dist/Server/API/AIChatAPI.js +6 -1
  636. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  637. package/build/dist/Server/API/AIInvestigationAPI.js +17 -10
  638. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  639. package/build/dist/Server/API/AutoRemediationAPI.js +374 -24
  640. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  641. package/build/dist/Server/API/KubernetesClusterAiAccessAPI.js +436 -22
  642. package/build/dist/Server/API/KubernetesClusterAiAccessAPI.js.map +1 -1
  643. package/build/dist/Server/API/MicrosoftTeamsAPI.js +69 -13
  644. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  645. package/build/dist/Server/API/ProxmoxResourceAPI.js +187 -14
  646. package/build/dist/Server/API/ProxmoxResourceAPI.js.map +1 -1
  647. package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
  648. package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
  649. package/build/dist/Server/Infrastructure/GlobalCache.js +27 -0
  650. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  651. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.js +50 -0
  652. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796000000000-AddTeamComplianceRuleScope.js.map +1 -0
  653. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.js +84 -0
  654. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796100000000-AddKubernetesAiAgentAndAiDefaults.js.map +1 -0
  655. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.js +31 -0
  656. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796200000000-AddProxmoxResourceNativePushColumns.js.map +1 -0
  657. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
  658. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
  659. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  660. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  661. package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
  662. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  663. package/build/dist/Server/Middleware/TelemetryIngest.js +41 -19
  664. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  665. package/build/dist/Server/Services/AIService.js +59 -1
  666. package/build/dist/Server/Services/AIService.js.map +1 -1
  667. package/build/dist/Server/Services/AlertService.js +248 -13
  668. package/build/dist/Server/Services/AlertService.js.map +1 -1
  669. package/build/dist/Server/Services/AlertSeverityService.js +51 -15
  670. package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
  671. package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
  672. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  673. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
  674. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  675. package/build/dist/Server/Services/AutoRemediationRuleService.js +1 -1
  676. package/build/dist/Server/Services/AutoRemediationRuleService.js.map +1 -1
  677. package/build/dist/Server/Services/CephClusterService.js +104 -0
  678. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  679. package/build/dist/Server/Services/DatabaseServerService.js +99 -0
  680. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
  681. package/build/dist/Server/Services/DockerHostService.js +104 -0
  682. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  683. package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
  684. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  685. package/build/dist/Server/Services/HostService.js +114 -2
  686. package/build/dist/Server/Services/HostService.js.map +1 -1
  687. package/build/dist/Server/Services/IncidentService.js +199 -72
  688. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  689. package/build/dist/Server/Services/IncidentSeverityService.js +51 -15
  690. package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
  691. package/build/dist/Server/Services/KubernetesAiAgentJobService.js +158 -0
  692. package/build/dist/Server/Services/KubernetesAiAgentJobService.js.map +1 -0
  693. package/build/dist/Server/Services/KubernetesAiAgentService.js +1145 -0
  694. package/build/dist/Server/Services/KubernetesAiAgentService.js.map +1 -0
  695. package/build/dist/Server/Services/KubernetesClusterAiAccessService.js +842 -371
  696. package/build/dist/Server/Services/KubernetesClusterAiAccessService.js.map +1 -1
  697. package/build/dist/Server/Services/KubernetesClusterService.js +86 -17
  698. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  699. package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
  700. package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
  701. package/build/dist/Server/Services/PodmanHostService.js +104 -0
  702. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  703. package/build/dist/Server/Services/ProjectService.js +22 -0
  704. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  705. package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
  706. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  707. package/build/dist/Server/Services/ProxmoxResourceService.js +258 -3
  708. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  709. package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
  710. package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
  711. package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
  712. package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
  713. package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
  714. package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
  715. package/build/dist/Server/Services/RunnerJobService.js +522 -97
  716. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  717. package/build/dist/Server/Services/RunnerService.js +1 -1
  718. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  719. package/build/dist/Server/Services/ScheduledMaintenanceService.js +535 -3
  720. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  721. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +316 -46
  722. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  723. package/build/dist/Server/Services/TeamComplianceSettingService.js +833 -7
  724. package/build/dist/Server/Services/TeamComplianceSettingService.js.map +1 -1
  725. package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
  726. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  727. package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
  728. package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
  729. package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
  730. package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
  731. package/build/dist/Server/Utils/AI/ClusterAccess/ClusterAccessContext.js +52 -9
  732. package/build/dist/Server/Utils/AI/ClusterAccess/ClusterAccessContext.js.map +1 -1
  733. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.js +13 -9
  734. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlInvestigationToolkit.js.map +1 -1
  735. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlJobRunner.js +7 -3
  736. package/build/dist/Server/Utils/AI/ClusterAccess/KubectlJobRunner.js.map +1 -1
  737. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1056 -43
  738. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
  739. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +683 -42
  740. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
  741. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
  742. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
  743. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
  744. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
  745. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
  746. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
  747. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
  748. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
  749. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
  750. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
  751. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
  752. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
  753. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
  754. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
  755. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +89 -9
  756. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  757. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
  758. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  759. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
  760. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  761. package/build/dist/Server/Utils/AI/SRE/IncidentPostmortemRunner.js +30 -3
  762. package/build/dist/Server/Utils/AI/SRE/IncidentPostmortemRunner.js.map +1 -1
  763. package/build/dist/Server/Utils/AI/SRE/InvestigationEligibility.js +13 -2
  764. package/build/dist/Server/Utils/AI/SRE/InvestigationEligibility.js.map +1 -1
  765. package/build/dist/Server/Utils/AI/SRE/InvestigationGrader.js +1 -1
  766. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +294 -15
  767. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
  768. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
  769. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
  770. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
  771. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  772. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
  773. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
  774. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
  775. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
  776. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +111 -6
  777. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  778. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +40 -4
  779. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
  780. package/build/dist/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.js +296 -0
  781. package/build/dist/Server/Utils/Telemetry/ProxmoxNativeNodeLiveness.js.map +1 -0
  782. package/build/dist/Server/Utils/Telemetry/ProxmoxNativePush.js +699 -0
  783. package/build/dist/Server/Utils/Telemetry/ProxmoxNativePush.js.map +1 -0
  784. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +77 -19
  785. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  786. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
  787. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  788. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
  789. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
  790. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
  791. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  792. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
  793. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  794. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
  795. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
  796. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
  797. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
  798. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +768 -152
  799. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  800. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
  801. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
  802. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
  803. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
  804. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
  805. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
  806. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
  807. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
  808. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
  809. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
  810. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
  811. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
  812. package/build/dist/Types/AI/AIRunType.js +9 -4
  813. package/build/dist/Types/AI/AIRunType.js.map +1 -1
  814. package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
  815. package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
  816. package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
  817. package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
  818. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +126 -45
  819. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
  820. package/build/dist/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.js +28 -0
  821. package/build/dist/Types/AutoRemediation/AiRemediationCommandPolicyVerdict.js.map +1 -0
  822. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccess.js +72 -16
  823. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccess.js.map +1 -1
  824. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccessPermissions.js +20 -16
  825. package/build/dist/Types/Kubernetes/KubernetesClusterAiAccessPermissions.js.map +1 -1
  826. package/build/dist/Types/Kubernetes/KubernetesClusterAiInsights.js +17 -0
  827. package/build/dist/Types/Kubernetes/KubernetesClusterAiInsights.js.map +1 -0
  828. package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js +4 -4
  829. package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js.map +1 -1
  830. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
  831. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  832. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
  833. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  834. package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
  835. package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
  836. package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
  837. package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
  838. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
  839. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
  840. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
  841. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
  842. package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
  843. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  844. package/build/dist/Types/Team/ComplianceNotificationChannel.js +23 -0
  845. package/build/dist/Types/Team/ComplianceNotificationChannel.js.map +1 -0
  846. package/build/dist/Types/Team/ComplianceRule.js +304 -0
  847. package/build/dist/Types/Team/ComplianceRule.js.map +1 -0
  848. package/build/dist/Types/Team/ComplianceRuleType.js +21 -0
  849. package/build/dist/Types/Team/ComplianceRuleType.js.map +1 -1
  850. package/build/dist/Types/Team/TeamComplianceStatus.js +2 -0
  851. package/build/dist/Types/Team/TeamComplianceStatus.js.map +1 -0
  852. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +36 -0
  853. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
  854. package/build/dist/UI/Components/ActionButton/ActionButtonSchema.js +20 -1
  855. package/build/dist/UI/Components/ActionButton/ActionButtonSchema.js.map +1 -1
  856. package/build/dist/UI/Components/ActionButton/RowActions.js +113 -0
  857. package/build/dist/UI/Components/ActionButton/RowActions.js.map +1 -0
  858. package/build/dist/UI/Components/ActionButton/SplitActionButtons.js +71 -0
  859. package/build/dist/UI/Components/ActionButton/SplitActionButtons.js.map +1 -0
  860. package/build/dist/UI/Components/Charts/Area/AreaChart.js +16 -1
  861. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  862. package/build/dist/UI/Components/Charts/Bar/BarChart.js +16 -1
  863. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  864. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +27 -5
  865. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  866. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +61 -194
  867. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  868. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +84 -26
  869. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  870. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +75 -196
  871. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  872. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js +133 -0
  873. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js.map +1 -1
  874. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.js +26 -0
  875. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartBucketsChange.js.map +1 -0
  876. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.js +371 -0
  877. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/UseChartRangeSelection.js.map +1 -0
  878. package/build/dist/UI/Components/Charts/Line/LineChart.js +16 -1
  879. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  880. package/build/dist/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.js +35 -0
  881. package/build/dist/UI/Components/Charts/TimeRangeZoom/ResetTimeRangeZoomButton.js.map +1 -0
  882. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.js +107 -0
  883. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomContext.js.map +1 -0
  884. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.js +41 -0
  885. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomHint.js.map +1 -0
  886. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.js +117 -0
  887. package/build/dist/UI/Components/Charts/TimeRangeZoom/TimeRangeZoomUtil.js.map +1 -0
  888. package/build/dist/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.js +121 -0
  889. package/build/dist/UI/Components/Charts/TimeRangeZoom/UseTimeRangeZoom.js.map +1 -0
  890. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +15 -7
  891. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  892. package/build/dist/UI/Components/Charts/Utils/XAxis.js +70 -0
  893. package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
  894. package/build/dist/UI/Components/Charts/Utils/useHistogramRangeSelection.js +305 -38
  895. package/build/dist/UI/Components/Charts/Utils/useHistogramRangeSelection.js.map +1 -1
  896. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +89 -14
  897. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -1
  898. package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -2
  899. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  900. package/build/dist/UI/Components/Input/Input.js +69 -54
  901. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  902. package/build/dist/UI/Components/List/ListRow.js +4 -39
  903. package/build/dist/UI/Components/List/ListRow.js.map +1 -1
  904. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +31 -8
  905. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  906. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +10 -1
  907. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  908. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +139 -21
  909. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  910. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +30 -8
  911. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  912. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
  913. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  914. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +10 -0
  915. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  916. package/build/dist/UI/Components/MoreMenu/MoreMenu.js +253 -14
  917. package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
  918. package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js +43 -13
  919. package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js.map +1 -1
  920. package/build/dist/UI/Components/OrderedStatesList/Item.js +2 -38
  921. package/build/dist/UI/Components/OrderedStatesList/Item.js.map +1 -1
  922. package/build/dist/UI/Components/StatusPage/ResourceGrid.js +53 -15
  923. package/build/dist/UI/Components/StatusPage/ResourceGrid.js.map +1 -1
  924. package/build/dist/UI/Components/Table/TableBody.js +6 -2
  925. package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
  926. package/build/dist/UI/Components/Table/TableRow.js +12 -75
  927. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  928. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +32 -7
  929. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  930. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +27 -9
  931. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  932. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +11 -1
  933. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  934. package/build/dist/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.js +272 -0
  935. package/build/dist/UI/Components/TelemetryViewer/useViewerTimeRangeZoom.js.map +1 -0
  936. package/build/dist/UI/Components/Toggle/Toggle.js +14 -1
  937. package/build/dist/UI/Components/Toggle/Toggle.js.map +1 -1
  938. package/build/dist/UI/Components/Tooltip/Tooltip.js +1 -1
  939. package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
  940. package/build/dist/Utils/AI/InvestigationReport.js +71 -11
  941. package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
  942. package/build/dist/Utils/AiRemediation/CommandPolicy.js +1 -1
  943. package/build/dist/Utils/AiRemediation/CommandPolicy.js.map +1 -1
  944. package/build/dist/Utils/AiRemediation/KubectlPolicy.js +1 -1
  945. package/build/dist/Utils/AiRemediation/KubectlPolicy.js.map +1 -1
  946. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
  947. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
  948. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
  949. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
  950. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
  951. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
  952. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
  953. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
  954. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
  955. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
  956. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
  957. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
  958. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
  959. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
  960. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
  961. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
  962. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
  963. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
  964. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
  965. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
  966. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
  967. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
  968. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
  969. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
  970. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
  971. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
  972. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js +27 -1
  973. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js.map +1 -1
  974. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
  975. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
  976. package/package.json +1 -1
@@ -1,191 +1,4041 @@
1
- import TeamComplianceSettingService from "../../../Server/Services/TeamComplianceSettingService";
1
+ import TeamComplianceSettingService, {
2
+ ComplianceRuleScope,
3
+ DeletedSeverity,
4
+ DUPLICATE_COMPLIANCE_RULE_MESSAGE,
5
+ OPTIONS_DIFFER_MESSAGE,
6
+ SEVERITIES_DELETED_ENABLE_MESSAGE,
7
+ SEVERITIES_DELETED_OPTION,
8
+ TeamComplianceSettingService as TeamComplianceSettingServiceClass,
9
+ } from "../../../Server/Services/TeamComplianceSettingService";
10
+ import EnterpriseEdition from "../../../Server/Enterprise/EnterpriseEdition";
11
+ import AlertSeverityService from "../../../Server/Services/AlertSeverityService";
12
+ import IncidentSeverityService from "../../../Server/Services/IncidentSeverityService";
2
13
  import CreateBy from "../../../Server/Types/Database/CreateBy";
3
- import { OnCreate } from "../../../Server/Types/Database/Hooks";
14
+ import { OnCreate, OnUpdate } from "../../../Server/Types/Database/Hooks";
15
+ import ModelPermission from "../../../Server/Types/Database/Permissions/Index";
16
+ import UpdateBy from "../../../Server/Types/Database/UpdateBy";
17
+ import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
18
+ import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
4
19
  import TeamComplianceSetting from "../../../Models/DatabaseModels/TeamComplianceSetting";
20
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
21
+ import Includes from "../../../Types/BaseDatabase/Includes";
22
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
5
23
  import BadDataException from "../../../Types/Exception/BadDataException";
24
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
25
+ import { JSONObject } from "../../../Types/JSON";
6
26
  import ObjectID from "../../../Types/ObjectID";
7
- import { afterEach, describe, expect, test } from "@jest/globals";
27
+ import Permission, {
28
+ UserTenantAccessPermission,
29
+ } from "../../../Types/Permission";
30
+ import PositiveNumber from "../../../Types/PositiveNumber";
31
+ import ComplianceNotificationChannel from "../../../Types/Team/ComplianceNotificationChannel";
32
+ import ComplianceRule, {
33
+ COMPLIANCE_RULE_DEFINITIONS,
34
+ ComplianceRuleDefinition,
35
+ ComplianceSeverityKind,
36
+ } from "../../../Types/Team/ComplianceRule";
37
+ import ComplianceRuleType from "../../../Types/Team/ComplianceRuleType";
38
+ import UserType from "../../../Types/UserType";
39
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
40
+ import { FindOperator } from "typeorm";
8
41
 
9
42
  /*
10
- * A team may hold at most one compliance setting per rule type: two rows for
11
- * the same rule would leave the compliance check with no defined answer. The
12
- * guard lives in onBeforeCreate, and these tests hold it to refusing loudly
13
- * (BadDataException naming the rule type), to scoping the duplicate lookup by
14
- * team *and* rule type -- so the same rule on another team is still allowed --
15
- * and to running that lookup as root, since the row that already exists may be
16
- * one the caller cannot see.
43
+ * TeamComplianceSettingService guards what a team compliance rule may say.
44
+ *
45
+ * A rule is a type (ComplianceRuleType), optionally a channel ("Call") and a
46
+ * severity scope ("Critical"). One team may hold several rules of one type -
47
+ * "Call for Critical incidents" and "Push for Critical incidents" are both
48
+ * HasIncidentOnCallRules - so there is no unique index any more; the service
49
+ * refuses EXACT duplicates instead (same type, channel and severity set).
50
+ *
51
+ * These tests pin, against an in-memory team of stored rules and project
52
+ * severities:
53
+ *
54
+ * - create refuses unknown rule types and channels, and anything without a
55
+ * project or team, before reading anything;
56
+ * - options a rule type does not use are cleared, per kind, so nothing is
57
+ * stored that looks like part of a rule without being checked;
58
+ * - selected severities must exist in the rule's own project, of the rule's
59
+ * own kind - checked in one count, as root, and never for a stray option;
60
+ * - duplicates are judged on content (order, repeats and id case do not
61
+ * matter), legacy "no channel, every severity" rows included, while a
62
+ * different channel, severity set, rule type or team is a different rule;
63
+ * - an update that only switches a rule off reads nothing at all; one that
64
+ * switches it on reads only whether a severity delete left it with
65
+ * nothing to check, and refuses that;
66
+ * - a rule a severity delete emptied (options.severitiesDeleted) duplicates
67
+ * nothing, and any re-scope clears the mark in the same write;
68
+ * - an update that changes the scope re-validates it, compares against
69
+ * siblings excluding the row itself, reads only inside the caller's
70
+ * project, and a rule type change clears the options the new type does not
71
+ * use, including ones already stored on the row;
72
+ * - the static scope helpers the ee server can reuse (getIds, getScope,
73
+ * isSameScope).
74
+ *
75
+ * The protected hooks are called directly, as the other service tests do; a
76
+ * last block drives updateOneById - the path the API takes - to show that what
77
+ * the hook normalises is what reaches the write.
78
+ *
79
+ * The caller is a signed-in team editor (EditProjectTeam) of the project
80
+ * unless a test says otherwise: the hooks check the caller's permission
81
+ * before they read anything, so a caller with no say in the project learns
82
+ * nothing about its severities or rules from what they refuse.
17
83
  */
18
84
 
19
- type OnBeforeCreateFunction = (
20
- createBy: CreateBy<TeamComplianceSetting>,
21
- ) => Promise<OnCreate<TeamComplianceSetting>>;
22
-
23
- const TEAM_ID: ObjectID = new ObjectID("11111111-1111-4111-8111-111111111111");
24
- const OTHER_TEAM_ID: ObjectID = new ObjectID(
85
+ const PROJECT_ID: ObjectID = new ObjectID(
86
+ "11111111-1111-4111-8111-111111111111",
87
+ );
88
+ const OTHER_PROJECT_ID: ObjectID = new ObjectID(
25
89
  "22222222-2222-4222-8222-222222222222",
26
90
  );
27
- const RULE_TYPE: string = "RequireTwoFactorAuth";
91
+ const TEAM_ID: ObjectID = new ObjectID("33333333-3333-4333-8333-333333333333");
92
+ const OTHER_TEAM_ID: ObjectID = new ObjectID(
93
+ "44444444-4444-4444-8444-444444444444",
94
+ );
95
+ const USER_ID: ObjectID = new ObjectID("55555555-5555-4555-8555-555555555555");
28
96
 
29
- function onBeforeCreate(
30
- createBy: CreateBy<TeamComplianceSetting>,
31
- ): Promise<OnCreate<TeamComplianceSetting>> {
32
- return (
33
- TeamComplianceSettingService as unknown as {
34
- onBeforeCreate: OnBeforeCreateFunction;
97
+ const SETTING_ID: string = "66666666-6666-4666-8666-000000000001";
98
+ const SIBLING_SETTING_ID: string = "66666666-6666-4666-8666-000000000002";
99
+ const OTHER_PROJECT_SETTING_ID: string = "66666666-6666-4666-8666-000000000003";
100
+
101
+ const CRITICAL_INCIDENT: string = "c1c1c1c1-0000-4000-8000-000000000001";
102
+ const MAJOR_INCIDENT: string = "c1c1c1c1-0000-4000-8000-000000000002";
103
+ const MINOR_INCIDENT: string = "c1c1c1c1-0000-4000-8000-000000000003";
104
+ const FOREIGN_INCIDENT: string = "c1c1c1c1-0000-4000-8000-000000000009";
105
+
106
+ const CRITICAL_ALERT: string = "a1a1a1a1-0000-4000-8000-000000000001";
107
+ const MAJOR_ALERT: string = "a1a1a1a1-0000-4000-8000-000000000002";
108
+ const FOREIGN_ALERT: string = "a1a1a1a1-0000-4000-8000-000000000009";
109
+
110
+ const UNKNOWN_CHANNEL_MESSAGE: (value: string) => string = (
111
+ value: string,
112
+ ): string => {
113
+ return `"${value}" is not a notification channel a compliance rule can require.`;
114
+ };
115
+
116
+ const UNKNOWN_RULE_TYPE_MESSAGE: (value: string) => string = (
117
+ value: string,
118
+ ): string => {
119
+ return `"${value}" is not a compliance rule type.`;
120
+ };
121
+
122
+ const FOREIGN_INCIDENT_SEVERITY_MESSAGE: string =
123
+ "One or more of the selected incident severities do not exist in this project.";
124
+ const FOREIGN_ALERT_SEVERITY_MESSAGE: string =
125
+ "One or more of the selected alert severities do not exist in this project.";
126
+
127
+ const ON_CALL_RULE_TYPES: Array<ComplianceRuleType> = [
128
+ ComplianceRuleType.HasIncidentOnCallRules,
129
+ ComplianceRuleType.HasAlertOnCallRules,
130
+ ComplianceRuleType.HasIncidentEpisodeOnCallRules,
131
+ ComplianceRuleType.HasAlertEpisodeOnCallRules,
132
+ ];
133
+
134
+ const INCIDENT_RULE_TYPES: Array<ComplianceRuleType> = [
135
+ ComplianceRuleType.HasIncidentOnCallRules,
136
+ ComplianceRuleType.HasIncidentEpisodeOnCallRules,
137
+ ];
138
+
139
+ const ALERT_RULE_TYPES: Array<ComplianceRuleType> = [
140
+ ComplianceRuleType.HasAlertOnCallRules,
141
+ ComplianceRuleType.HasAlertEpisodeOnCallRules,
142
+ ];
143
+
144
+ const METHOD_RULE_TYPES: Array<ComplianceRuleType> = [
145
+ ComplianceRuleType.HasNotificationEmailMethod,
146
+ ComplianceRuleType.HasNotificationSMSMethod,
147
+ ComplianceRuleType.HasNotificationCallMethod,
148
+ ComplianceRuleType.HasNotificationPushMethod,
149
+ ComplianceRuleType.HasNotificationWhatsAppMethod,
150
+ ComplianceRuleType.HasNotificationTelegramMethod,
151
+ ComplianceRuleType.HasNotificationSlackMethod,
152
+ ComplianceRuleType.HasNotificationMicrosoftTeamsMethod,
153
+ ComplianceRuleType.HasNotificationWebhookMethod,
154
+ ];
155
+
156
+ const ALL_CHANNELS: Array<ComplianceNotificationChannel> = Object.values(
157
+ ComplianceNotificationChannel,
158
+ );
159
+
160
+ /*
161
+ * ---------------------------------------------------------------------------
162
+ * The in-memory world the service reads from.
163
+ * ---------------------------------------------------------------------------
164
+ */
165
+
166
+ // The rules already stored, across every team and project.
167
+ let storedSettings: Array<TeamComplianceSetting> = [];
168
+
169
+ // Severity ids per project, as the severity tables hold them (lower case).
170
+ const INCIDENT_SEVERITIES_BY_PROJECT: Map<string, Array<string>> = new Map<
171
+ string,
172
+ Array<string>
173
+ >([
174
+ [PROJECT_ID.toString(), [CRITICAL_INCIDENT, MAJOR_INCIDENT, MINOR_INCIDENT]],
175
+ [OTHER_PROJECT_ID.toString(), [FOREIGN_INCIDENT]],
176
+ ]);
177
+
178
+ const ALERT_SEVERITIES_BY_PROJECT: Map<string, Array<string>> = new Map<
179
+ string,
180
+ Array<string>
181
+ >([
182
+ [PROJECT_ID.toString(), [CRITICAL_ALERT, MAJOR_ALERT]],
183
+ [OTHER_PROJECT_ID.toString(), [FOREIGN_ALERT]],
184
+ ]);
185
+
186
+ let settingsFindBy: jest.SpyInstance;
187
+ let settingsFindOneBy: jest.SpyInstance;
188
+ let settingsCountBy: jest.SpyInstance;
189
+ let incidentSeverityCountBy: jest.SpyInstance;
190
+ let alertSeverityCountBy: jest.SpyInstance;
191
+
192
+ /*
193
+ * A query as DatabaseService would run it: every key must match, ids compare
194
+ * as strings, Includes matches any of its values, and QueryHelper.notEquals -
195
+ * a Raw "!=" carrying the excluded id as its only parameter - excludes that
196
+ * row.
197
+ */
198
+ const matchesQuery: (
199
+ row: TeamComplianceSetting,
200
+ query: JSONObject,
201
+ ) => boolean = (row: TeamComplianceSetting, query: JSONObject): boolean => {
202
+ return Object.keys(query).every((key: string): boolean => {
203
+ const expected: unknown = query[key];
204
+ const actual: unknown = (row as unknown as Record<string, unknown>)[key];
205
+
206
+ if (expected instanceof Includes) {
207
+ return expected.values
208
+ .map((value: unknown): string => {
209
+ return String(value);
210
+ })
211
+ .includes(String(actual));
212
+ }
213
+
214
+ if (expected instanceof FindOperator) {
215
+ const excluded: Array<string> = Object.values(
216
+ expected.objectLiteralParameters || {},
217
+ ).map((value: unknown): string => {
218
+ return String(value);
219
+ });
220
+
221
+ return !excluded.includes(String(actual));
35
222
  }
36
- ).onBeforeCreate(createBy);
223
+
224
+ return String(actual) === String(expected);
225
+ });
226
+ };
227
+
228
+ /*
229
+ * countBy({_id: Includes(ids), projectId}) the way Postgres answers it: the
230
+ * number of severity ROWS in that project whose uuid is in the list, uuids
231
+ * compared without case.
232
+ */
233
+ const countSeverities: (
234
+ severitiesByProject: Map<string, Array<string>>,
235
+ query: JSONObject,
236
+ ) => PositiveNumber = (
237
+ severitiesByProject: Map<string, Array<string>>,
238
+ query: JSONObject,
239
+ ): PositiveNumber => {
240
+ const wanted: Array<string> = (query["_id"] as Includes).values.map(
241
+ (id: unknown): string => {
242
+ return String(id).toLowerCase();
243
+ },
244
+ );
245
+
246
+ const inProject: Array<string> =
247
+ severitiesByProject.get(String(query["projectId"])) || [];
248
+
249
+ return new PositiveNumber(
250
+ inProject.filter((id: string): boolean => {
251
+ return wanted.includes(id);
252
+ }).length,
253
+ );
254
+ };
255
+
256
+ beforeEach(() => {
257
+ storedSettings = [];
258
+
259
+ /*
260
+ * TeamComplianceSetting is enterprise configuration, and the permission
261
+ * check asks the licence; no test machine holds one.
262
+ */
263
+ jest
264
+ .spyOn(EnterpriseEdition, "assertFeatureAvailableSync")
265
+ .mockReturnValue(undefined);
266
+
267
+ settingsFindBy = jest
268
+ .spyOn(TeamComplianceSettingService, "findBy")
269
+ .mockImplementation(((findBy: { query: JSONObject }) => {
270
+ return Promise.resolve(
271
+ storedSettings.filter((row: TeamComplianceSetting): boolean => {
272
+ return matchesQuery(row, findBy.query);
273
+ }),
274
+ );
275
+ }) as never);
276
+
277
+ // The service reads through findBy only; any other read is a regression.
278
+ settingsFindOneBy = jest
279
+ .spyOn(TeamComplianceSettingService, "findOneBy")
280
+ .mockRejectedValue(new Error("unexpected findOneBy") as never);
281
+ settingsCountBy = jest
282
+ .spyOn(TeamComplianceSettingService, "countBy")
283
+ .mockRejectedValue(new Error("unexpected countBy") as never);
284
+
285
+ incidentSeverityCountBy = jest
286
+ .spyOn(IncidentSeverityService, "countBy")
287
+ .mockImplementation(((countBy: { query: JSONObject }) => {
288
+ return Promise.resolve(
289
+ countSeverities(INCIDENT_SEVERITIES_BY_PROJECT, countBy.query),
290
+ );
291
+ }) as never);
292
+
293
+ alertSeverityCountBy = jest
294
+ .spyOn(AlertSeverityService, "countBy")
295
+ .mockImplementation(((countBy: { query: JSONObject }) => {
296
+ return Promise.resolve(
297
+ countSeverities(ALERT_SEVERITIES_BY_PROJECT, countBy.query),
298
+ );
299
+ }) as never);
300
+ });
301
+
302
+ afterEach(() => {
303
+ jest.restoreAllMocks();
304
+ });
305
+
306
+ /*
307
+ * ---------------------------------------------------------------------------
308
+ * Builders.
309
+ * ---------------------------------------------------------------------------
310
+ */
311
+
312
+ interface StoredRuleInput {
313
+ id: string;
314
+ ruleType: string;
315
+ teamId?: ObjectID | undefined;
316
+ projectId?: ObjectID | undefined;
317
+ notificationChannel?: string | null | undefined;
318
+ incidentSeverityIds?: Array<string> | undefined;
319
+ alertSeverityIds?: Array<string> | undefined;
320
+ // Stored rules are enabled unless a test pauses one.
321
+ enabled?: boolean | undefined;
322
+ // The row's options JSON; unset (NULL) unless a test gives it some.
323
+ options?: JSONObject | undefined;
324
+ omitTeam?: boolean | undefined;
325
+ omitProject?: boolean | undefined;
37
326
  }
38
327
 
39
- function buildCreateBy(data: {
40
- teamId?: ObjectID;
41
- ruleType?: string;
42
- }): CreateBy<TeamComplianceSetting> {
328
+ // A row as the service's findBy returns it, relations loaded as models.
329
+ const storedRule: (input: StoredRuleInput) => TeamComplianceSetting = (
330
+ input: StoredRuleInput,
331
+ ): TeamComplianceSetting => {
43
332
  const setting: TeamComplianceSetting = new TeamComplianceSetting();
44
- if (data.teamId) {
45
- setting.teamId = data.teamId;
333
+ setting._id = input.id;
334
+
335
+ if (!input.omitTeam) {
336
+ setting.teamId = input.teamId || TEAM_ID;
46
337
  }
47
- if (data.ruleType) {
48
- (setting as unknown as Record<string, unknown>)["ruleType"] = data.ruleType;
338
+
339
+ if (!input.omitProject) {
340
+ setting.projectId = input.projectId || PROJECT_ID;
49
341
  }
50
- return {
51
- data: setting,
52
- props: { isRoot: true },
53
- } as CreateBy<TeamComplianceSetting>;
342
+
343
+ (setting as unknown as Record<string, unknown>)["ruleType"] = input.ruleType;
344
+ (setting as unknown as Record<string, unknown>)["notificationChannel"] =
345
+ input.notificationChannel === undefined ? null : input.notificationChannel;
346
+ setting.enabled = input.enabled === undefined ? true : input.enabled;
347
+
348
+ if (input.options !== undefined) {
349
+ setting.options = input.options;
350
+ }
351
+
352
+ setting.incidentSeverities = (input.incidentSeverityIds || []).map(
353
+ (id: string): IncidentSeverity => {
354
+ const severity: IncidentSeverity = new IncidentSeverity();
355
+ severity._id = id;
356
+ return severity;
357
+ },
358
+ );
359
+
360
+ setting.alertSeverities = (input.alertSeverityIds || []).map(
361
+ (id: string): AlertSeverity => {
362
+ const severity: AlertSeverity = new AlertSeverity();
363
+ severity._id = id;
364
+ return severity;
365
+ },
366
+ );
367
+
368
+ return setting;
369
+ };
370
+
371
+ const store: (...rows: Array<StoredRuleInput>) => void = (
372
+ ...rows: Array<StoredRuleInput>
373
+ ): void => {
374
+ for (const row of rows) {
375
+ storedSettings.push(storedRule(row));
376
+ }
377
+ };
378
+
379
+ interface NewRuleInput {
380
+ ruleType?: unknown;
381
+ notificationChannel?: unknown;
382
+ incidentSeverities?: unknown;
383
+ alertSeverities?: unknown;
384
+ teamId?: ObjectID | null | undefined;
385
+ projectId?: ObjectID | null | undefined;
54
386
  }
55
387
 
56
- function mockExisting(
57
- existing: TeamComplianceSetting | null,
58
- ): jest.SpyInstance {
59
- return jest
60
- .spyOn(TeamComplianceSettingService, "findOneBy")
61
- .mockResolvedValue(existing as never);
388
+ // The payload of a create, as BaseAPI hands it over: a model, any values.
389
+ const newRule: (input: NewRuleInput) => TeamComplianceSetting = (
390
+ input: NewRuleInput,
391
+ ): TeamComplianceSetting => {
392
+ const setting: TeamComplianceSetting = new TeamComplianceSetting();
393
+ const target: Record<string, unknown> = setting as unknown as Record<
394
+ string,
395
+ unknown
396
+ >;
397
+
398
+ if (input.teamId !== null) {
399
+ setting.teamId = input.teamId || TEAM_ID;
400
+ }
401
+
402
+ if (input.projectId !== null) {
403
+ setting.projectId = input.projectId || PROJECT_ID;
404
+ }
405
+
406
+ for (const key of [
407
+ "ruleType",
408
+ "notificationChannel",
409
+ "incidentSeverities",
410
+ "alertSeverities",
411
+ ]) {
412
+ if (key in input) {
413
+ target[key] = (input as Record<string, unknown>)[key];
414
+ }
415
+ }
416
+
417
+ return setting;
418
+ };
419
+
420
+ // A signed-in user holding `permissions` in `project`, and nothing else.
421
+ const memberProps: (
422
+ permissions: Array<Permission>,
423
+ project?: ObjectID,
424
+ ) => DatabaseCommonInteractionProps = (
425
+ permissions: Array<Permission>,
426
+ project?: ObjectID,
427
+ ): DatabaseCommonInteractionProps => {
428
+ const tenantId: ObjectID = project || PROJECT_ID;
429
+
430
+ const tenantPermission: UserTenantAccessPermission = {
431
+ projectId: tenantId,
432
+ _type: "UserTenantAccessPermission",
433
+ permissions: permissions.map((permission: Permission) => {
434
+ return {
435
+ _type: "UserPermission",
436
+ permission: permission,
437
+ labelIds: [],
438
+ isBlockPermission: false,
439
+ };
440
+ }),
441
+ };
442
+
443
+ return {
444
+ tenantId: tenantId,
445
+ userId: USER_ID,
446
+ userType: UserType.User,
447
+ userTenantAccessPermission: {
448
+ [tenantId.toString()]: tenantPermission,
449
+ },
450
+ };
451
+ };
452
+
453
+ /*
454
+ * A team editor: Edit Teams, plus Read Teams to see what they edit. (An
455
+ * update is located by a query on the project, which only a reader of the
456
+ * rules may filter on - the update permission check refuses Edit Teams on
457
+ * its own, here and in _updateBy alike.)
458
+ */
459
+ const EDITOR_PROPS: DatabaseCommonInteractionProps = memberProps([
460
+ Permission.ReadProjectTeam,
461
+ Permission.EditProjectTeam,
462
+ ]);
463
+
464
+ /*
465
+ * Signed in, with the project named as the tenant, but no permission in it -
466
+ * what any user of another project can send.
467
+ */
468
+ const OUTSIDER_PROPS: DatabaseCommonInteractionProps = {
469
+ tenantId: PROJECT_ID,
470
+ userId: USER_ID,
471
+ userType: UserType.User,
472
+ };
473
+
474
+ const createProps: DatabaseCommonInteractionProps = EDITOR_PROPS;
475
+
476
+ const createByFor: (
477
+ data: TeamComplianceSetting,
478
+ props?: DatabaseCommonInteractionProps,
479
+ ) => CreateBy<TeamComplianceSetting> = (
480
+ data: TeamComplianceSetting,
481
+ props?: DatabaseCommonInteractionProps,
482
+ ): CreateBy<TeamComplianceSetting> => {
483
+ return {
484
+ data: data,
485
+ props: props || createProps,
486
+ };
487
+ };
488
+
489
+ type OnBeforeCreateFunction = (
490
+ createBy: CreateBy<TeamComplianceSetting>,
491
+ ) => Promise<OnCreate<TeamComplianceSetting>>;
492
+
493
+ type OnBeforeUpdateFunction = (
494
+ updateBy: UpdateBy<TeamComplianceSetting>,
495
+ ) => Promise<OnUpdate<TeamComplianceSetting>>;
496
+
497
+ interface ServiceHooks {
498
+ onBeforeCreate: OnBeforeCreateFunction;
499
+ onBeforeUpdate: OnBeforeUpdateFunction;
500
+ _onBeforeCreate: OnBeforeCreateFunction;
62
501
  }
63
502
 
64
- afterEach(() => {
65
- jest.restoreAllMocks();
66
- });
503
+ const hooks: () => ServiceHooks = (): ServiceHooks => {
504
+ return TeamComplianceSettingService as unknown as ServiceHooks;
505
+ };
67
506
 
68
- describe("TeamComplianceSettingService onBeforeCreate", () => {
69
- test("allows the first setting for a team and rule type", async () => {
70
- mockExisting(null);
507
+ const onBeforeCreate: (
508
+ createBy: CreateBy<TeamComplianceSetting>,
509
+ ) => Promise<OnCreate<TeamComplianceSetting>> = (
510
+ createBy: CreateBy<TeamComplianceSetting>,
511
+ ): Promise<OnCreate<TeamComplianceSetting>> => {
512
+ return hooks().onBeforeCreate(createBy);
513
+ };
71
514
 
72
- const createBy: CreateBy<TeamComplianceSetting> = buildCreateBy({
73
- teamId: TEAM_ID,
74
- ruleType: RULE_TYPE,
75
- });
76
- const result: OnCreate<TeamComplianceSetting> =
77
- await onBeforeCreate(createBy);
515
+ const create: (
516
+ input: NewRuleInput,
517
+ ) => Promise<OnCreate<TeamComplianceSetting>> = (
518
+ input: NewRuleInput,
519
+ ): Promise<OnCreate<TeamComplianceSetting>> => {
520
+ return onBeforeCreate(createByFor(newRule(input)));
521
+ };
522
+
523
+ const updateByFor: (
524
+ data: Record<string, unknown>,
525
+ options?: {
526
+ query?: Record<string, unknown> | undefined;
527
+ props?: DatabaseCommonInteractionProps | undefined;
528
+ },
529
+ ) => UpdateBy<TeamComplianceSetting> = (
530
+ data: Record<string, unknown>,
531
+ options?: {
532
+ query?: Record<string, unknown> | undefined;
533
+ props?: DatabaseCommonInteractionProps | undefined;
534
+ },
535
+ ): UpdateBy<TeamComplianceSetting> => {
536
+ return {
537
+ query: options?.query || { _id: SETTING_ID },
538
+ data: data,
539
+ limit: 1,
540
+ skip: 0,
541
+ props: options?.props || EDITOR_PROPS,
542
+ } as unknown as UpdateBy<TeamComplianceSetting>;
543
+ };
544
+
545
+ const onBeforeUpdate: (
546
+ updateBy: UpdateBy<TeamComplianceSetting>,
547
+ ) => Promise<OnUpdate<TeamComplianceSetting>> = (
548
+ updateBy: UpdateBy<TeamComplianceSetting>,
549
+ ): Promise<OnUpdate<TeamComplianceSetting>> => {
550
+ return hooks().onBeforeUpdate(updateBy);
551
+ };
78
552
 
79
- expect(result.createBy).toBe(createBy);
80
- expect(result.carryForward).toBeNull();
553
+ const update: (
554
+ data: Record<string, unknown>,
555
+ ) => Promise<OnUpdate<TeamComplianceSetting>> = (
556
+ data: Record<string, unknown>,
557
+ ): Promise<OnUpdate<TeamComplianceSetting>> => {
558
+ return onBeforeUpdate(updateByFor(data));
559
+ };
560
+
561
+ const findByCalls: () => Array<JSONObject> = (): Array<JSONObject> => {
562
+ return settingsFindBy.mock.calls.map((call: Array<unknown>): JSONObject => {
563
+ return call[0] as JSONObject;
564
+ });
565
+ };
566
+
567
+ /*
568
+ * The duplicate lookups. An update first reads the rows it changes, selecting
569
+ * their team and project; a duplicate lookup already knows both.
570
+ */
571
+ const siblingReads: () => Array<JSONObject> = (): Array<JSONObject> => {
572
+ return findByCalls().filter((call: JSONObject): boolean => {
573
+ return (call["select"] as JSONObject)["teamId"] === undefined;
81
574
  });
575
+ };
82
576
 
83
- test("refuses a second setting for the same team and rule type", async () => {
84
- const existing: TeamComplianceSetting = new TeamComplianceSetting();
85
- existing._id = "33333333-3333-4333-8333-333333333333";
86
- mockExisting(existing);
577
+ const countCalls: (spy: jest.SpyInstance) => Array<JSONObject> = (
578
+ spy: jest.SpyInstance,
579
+ ): Array<JSONObject> => {
580
+ return spy.mock.calls.map((call: Array<unknown>): JSONObject => {
581
+ return call[0] as JSONObject;
582
+ });
583
+ };
87
584
 
88
- await expect(
89
- onBeforeCreate(buildCreateBy({ teamId: TEAM_ID, ruleType: RULE_TYPE })),
90
- ).rejects.toThrow(BadDataException);
585
+ const includedIds: (query: JSONObject) => Array<string> = (
586
+ query: JSONObject,
587
+ ): Array<string> => {
588
+ return (query["_id"] as Includes).values.map((id: unknown): string => {
589
+ return String(id);
91
590
  });
591
+ };
92
592
 
93
- test("the refusal names the rule type, so the form can point at it", async () => {
94
- const existing: TeamComplianceSetting = new TeamComplianceSetting();
95
- existing._id = "33333333-3333-4333-8333-333333333333";
96
- mockExisting(existing);
593
+ const expectNoReads: () => void = (): void => {
594
+ expect(settingsFindBy).not.toHaveBeenCalled();
595
+ expect(settingsFindOneBy).not.toHaveBeenCalled();
596
+ expect(settingsCountBy).not.toHaveBeenCalled();
597
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
598
+ expect(alertSeverityCountBy).not.toHaveBeenCalled();
599
+ };
97
600
 
98
- await expect(
99
- onBeforeCreate(buildCreateBy({ teamId: TEAM_ID, ruleType: RULE_TYPE })),
100
- ).rejects.toThrow(
101
- `A compliance setting for rule type "${RULE_TYPE}" already exists for this team.`,
601
+ const dataOf: (
602
+ updateBy: UpdateBy<TeamComplianceSetting>,
603
+ ) => Record<string, unknown> = (
604
+ updateBy: UpdateBy<TeamComplianceSetting>,
605
+ ): Record<string, unknown> => {
606
+ return updateBy.data as unknown as Record<string, unknown>;
607
+ };
608
+
609
+ const modelValue: (setting: TeamComplianceSetting, key: string) => unknown = (
610
+ setting: TeamComplianceSetting,
611
+ key: string,
612
+ ): unknown => {
613
+ return (setting as unknown as Record<string, unknown>)[key];
614
+ };
615
+
616
+ /*
617
+ * A severity list as the hooks leave it for the relation save: models of the
618
+ * list's own kind, each carrying nothing but its id. Returns those ids.
619
+ */
620
+ const writtenIds: (
621
+ value: unknown,
622
+ modelType: typeof IncidentSeverity | typeof AlertSeverity,
623
+ ) => Array<string> = (
624
+ value: unknown,
625
+ modelType: typeof IncidentSeverity | typeof AlertSeverity,
626
+ ): Array<string> => {
627
+ expect(Array.isArray(value)).toBe(true);
628
+
629
+ return (value as Array<unknown>).map((item: unknown): string => {
630
+ expect(item).toBeInstanceOf(modelType);
631
+
632
+ const model: IncidentSeverity | AlertSeverity = item as
633
+ | IncidentSeverity
634
+ | AlertSeverity;
635
+
636
+ expect(model.name).toBeUndefined();
637
+ expect(model.projectId).toBeUndefined();
638
+
639
+ return String(model._id);
640
+ });
641
+ };
642
+
643
+ /*
644
+ * ---------------------------------------------------------------------------
645
+ * create
646
+ * ---------------------------------------------------------------------------
647
+ */
648
+
649
+ describe("TeamComplianceSettingService onBeforeCreate - what may be created", () => {
650
+ test.each(Object.values(ComplianceRuleType))(
651
+ "accepts a %s rule on a team that has none",
652
+ async (ruleType: ComplianceRuleType) => {
653
+ const createBy: CreateBy<TeamComplianceSetting> = createByFor(
654
+ newRule({ ruleType: ruleType }),
655
+ );
656
+
657
+ const result: OnCreate<TeamComplianceSetting> =
658
+ await onBeforeCreate(createBy);
659
+
660
+ // The hook hands back the very same request, nothing carried forward.
661
+ expect(result.createBy).toBe(createBy);
662
+ expect(result.carryForward).toBeNull();
663
+ expect(result.createBy.data.ruleType).toBe(ruleType);
664
+ },
665
+ );
666
+
667
+ test.each([
668
+ ["a rule type from the old free-text era", "RequireTwoFactorAuth"],
669
+ ["a near miss in case", "hasincidentoncallrules"],
670
+ ["an empty string", ""],
671
+ [
672
+ "a notification rule type, not a compliance one",
673
+ "WHEN_USER_GOES_ON_CALL",
674
+ ],
675
+ ])(
676
+ "refuses %s before reading anything",
677
+ async (_label: string, ruleType: string) => {
678
+ await expect(create({ ruleType: ruleType })).rejects.toThrow(
679
+ new BadDataException(UNKNOWN_RULE_TYPE_MESSAGE(ruleType)),
680
+ );
681
+
682
+ expectNoReads();
683
+ },
684
+ );
685
+
686
+ test.each([
687
+ ["missing", undefined],
688
+ ["null", null],
689
+ ["a number", 42],
690
+ ["an object", { ruleType: "HasIncidentOnCallRules" }],
691
+ ])(
692
+ "refuses a rule type that is %s",
693
+ async (_label: string, ruleType: unknown) => {
694
+ await expect(create({ ruleType: ruleType })).rejects.toThrow(
695
+ BadDataException,
696
+ );
697
+
698
+ expectNoReads();
699
+ },
700
+ );
701
+
702
+ test("the refusal is a BadDataException that names the value, so the form can show it", async () => {
703
+ await expect(create({ ruleType: "RequirePhoneNumber" })).rejects.toThrow(
704
+ '"RequirePhoneNumber" is not a compliance rule type.',
102
705
  );
103
706
  });
104
707
 
105
- test("the duplicate lookup is keyed on both the team and the rule type", async () => {
106
- const findSpy: jest.SpyInstance = mockExisting(null);
708
+ test.each(ALL_CHANNELS)(
709
+ "accepts the %s channel on every on-call rule type and keeps it",
710
+ async (channel: ComplianceNotificationChannel) => {
711
+ for (const ruleType of ON_CALL_RULE_TYPES) {
712
+ const result: OnCreate<TeamComplianceSetting> = await create({
713
+ ruleType: ruleType,
714
+ notificationChannel: channel,
715
+ });
716
+
717
+ expect({
718
+ ruleType,
719
+ channel: result.createBy.data.notificationChannel,
720
+ }).toEqual({ ruleType, channel });
721
+ }
722
+ },
723
+ );
724
+
725
+ test.each([
726
+ ["undefined", undefined],
727
+ ["null", null],
728
+ ])(
729
+ "a %s channel is 'any channel', and is valid",
730
+ async (_label: string, channel: unknown) => {
731
+ const result: OnCreate<TeamComplianceSetting> = await create({
732
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
733
+ notificationChannel: channel,
734
+ });
735
+
736
+ expect(modelValue(result.createBy.data, "notificationChannel")).toBe(
737
+ channel,
738
+ );
739
+ },
740
+ );
741
+
742
+ test.each([
743
+ ["a channel that does not exist", "Pager"],
744
+ ["a channel in the wrong case", "call"],
745
+ ["an empty string", ""],
746
+ ["a method rule type", "HasNotificationCallMethod"],
747
+ ])(
748
+ "refuses %s as the channel, before reading anything",
749
+ async (_label: string, channel: string) => {
750
+ await expect(
751
+ create({
752
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
753
+ notificationChannel: channel,
754
+ }),
755
+ ).rejects.toThrow(new BadDataException(UNKNOWN_CHANNEL_MESSAGE(channel)));
756
+
757
+ expectNoReads();
758
+ },
759
+ );
760
+
761
+ test("refuses a channel that is not a channel even on a method rule - garbage is refused, not quietly dropped", async () => {
762
+ await expect(
763
+ create({
764
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
765
+ notificationChannel: "Pager",
766
+ }),
767
+ ).rejects.toThrow(UNKNOWN_CHANNEL_MESSAGE("Pager"));
768
+ });
769
+
770
+ test("refuses a rule with no project, before reading anything", async () => {
771
+ await expect(
772
+ onBeforeCreate(
773
+ createByFor(
774
+ newRule({
775
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
776
+ projectId: null,
777
+ }),
778
+ { isRoot: true },
779
+ ),
780
+ ),
781
+ ).rejects.toThrow(new BadDataException("Project ID is required."));
782
+
783
+ expectNoReads();
784
+ });
785
+
786
+ test("refuses a rule with no team, before reading anything", async () => {
787
+ await expect(
788
+ create({
789
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
790
+ teamId: null,
791
+ }),
792
+ ).rejects.toThrow(new BadDataException("Team ID is required."));
793
+
794
+ expectNoReads();
795
+ });
107
796
 
797
+ test("takes the project from the caller's tenant when the payload has none", async () => {
108
798
  await onBeforeCreate(
109
- buildCreateBy({ teamId: TEAM_ID, ruleType: RULE_TYPE }),
799
+ createByFor(
800
+ newRule({
801
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
802
+ projectId: null,
803
+ incidentSeverities: [CRITICAL_INCIDENT],
804
+ }),
805
+ EDITOR_PROPS,
806
+ ),
110
807
  );
111
808
 
112
- const findArgs: Record<string, unknown> = findSpy.mock
113
- .calls[0]?.[0] as Record<string, unknown>;
114
- expect(findArgs["query"]).toEqual({
115
- teamId: TEAM_ID,
116
- ruleType: RULE_TYPE,
809
+ expect(countCalls(incidentSeverityCountBy)[0]?.["query"]).toMatchObject({
810
+ projectId: PROJECT_ID,
811
+ });
812
+ expect(siblingReads()[0]?.["query"]).toMatchObject({
813
+ projectId: PROJECT_ID,
814
+ });
815
+ });
816
+
817
+ test("a projectId smuggled into the payload cannot move the checks into another project", async () => {
818
+ /*
819
+ * DatabaseService.create stamps the tenant onto the payload before this
820
+ * hook runs (_onBeforeCreate), so the severity check and the duplicate
821
+ * lookup run in the caller's project, not the one the payload names.
822
+ */
823
+ await expect(
824
+ hooks()._onBeforeCreate(
825
+ createByFor(
826
+ newRule({
827
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
828
+ projectId: OTHER_PROJECT_ID,
829
+ incidentSeverities: [FOREIGN_INCIDENT],
830
+ }),
831
+ EDITOR_PROPS,
832
+ ),
833
+ ),
834
+ ).rejects.toThrow(FOREIGN_INCIDENT_SEVERITY_MESSAGE);
835
+
836
+ expect(countCalls(incidentSeverityCountBy)[0]?.["query"]).toMatchObject({
837
+ projectId: PROJECT_ID,
117
838
  });
118
- // Only the id is needed to know whether one exists.
119
- expect(findArgs["select"]).toEqual({ _id: true });
120
839
  });
840
+ });
121
841
 
122
- test("the lookup runs as root: the existing row may be invisible to the caller", async () => {
123
- const findSpy: jest.SpyInstance = mockExisting(null);
842
+ describe("TeamComplianceSettingService onBeforeCreate - options a rule type does not use are cleared", () => {
843
+ test.each(METHOD_RULE_TYPES)(
844
+ "%s keeps no channel and no severities of either kind",
845
+ async (ruleType: ComplianceRuleType) => {
846
+ const result: OnCreate<TeamComplianceSetting> = await create({
847
+ ruleType: ruleType,
848
+ notificationChannel: ComplianceNotificationChannel.Call,
849
+ incidentSeverities: [CRITICAL_INCIDENT],
850
+ alertSeverities: [CRITICAL_ALERT],
851
+ });
124
852
 
125
- await onBeforeCreate(
126
- buildCreateBy({ teamId: TEAM_ID, ruleType: RULE_TYPE }),
127
- );
128
-
129
- const findArgs: Record<string, unknown> = findSpy.mock
130
- .calls[0]?.[0] as Record<string, unknown>;
131
- expect(findArgs["props"]).toEqual({ isRoot: true });
132
- });
133
-
134
- test("the same rule type on a different team is a different row", async () => {
135
- const findSpy: jest.SpyInstance = jest
136
- .spyOn(TeamComplianceSettingService, "findOneBy")
137
- .mockImplementation(((
138
- args: Record<string, unknown>,
139
- ): Promise<unknown> => {
140
- const query: Record<string, unknown> = args["query"] as Record<
141
- string,
142
- unknown
143
- >;
144
- // Only the first team already holds this rule.
145
- if (query["teamId"] === TEAM_ID) {
146
- const existing: TeamComplianceSetting = new TeamComplianceSetting();
147
- existing._id = "33333333-3333-4333-8333-333333333333";
148
- return Promise.resolve(existing);
149
- }
150
- return Promise.resolve(null);
151
- }) as never);
853
+ expect(
854
+ modelValue(result.createBy.data, "notificationChannel"),
855
+ ).toBeNull();
856
+ expect(result.createBy.data.incidentSeverities).toEqual([]);
857
+ expect(result.createBy.data.alertSeverities).toEqual([]);
858
+ },
859
+ );
860
+
861
+ test.each(INCIDENT_RULE_TYPES)(
862
+ "%s keeps its channel and incident severities, and drops alert severities",
863
+ async (ruleType: ComplianceRuleType) => {
864
+ const result: OnCreate<TeamComplianceSetting> = await create({
865
+ ruleType: ruleType,
866
+ notificationChannel: ComplianceNotificationChannel.Push,
867
+ incidentSeverities: [CRITICAL_INCIDENT],
868
+ alertSeverities: [CRITICAL_ALERT],
869
+ });
152
870
 
871
+ expect(result.createBy.data.notificationChannel).toBe(
872
+ ComplianceNotificationChannel.Push,
873
+ );
874
+ expect(
875
+ writtenIds(
876
+ modelValue(result.createBy.data, "incidentSeverities"),
877
+ IncidentSeverity,
878
+ ),
879
+ ).toEqual([CRITICAL_INCIDENT]);
880
+ expect(result.createBy.data.alertSeverities).toEqual([]);
881
+ },
882
+ );
883
+
884
+ test.each(ALERT_RULE_TYPES)(
885
+ "%s keeps its channel and alert severities, and drops incident severities",
886
+ async (ruleType: ComplianceRuleType) => {
887
+ const result: OnCreate<TeamComplianceSetting> = await create({
888
+ ruleType: ruleType,
889
+ notificationChannel: ComplianceNotificationChannel.SMS,
890
+ incidentSeverities: [CRITICAL_INCIDENT],
891
+ alertSeverities: [CRITICAL_ALERT],
892
+ });
893
+
894
+ expect(result.createBy.data.notificationChannel).toBe(
895
+ ComplianceNotificationChannel.SMS,
896
+ );
897
+ expect(
898
+ writtenIds(
899
+ modelValue(result.createBy.data, "alertSeverities"),
900
+ AlertSeverity,
901
+ ),
902
+ ).toEqual([CRITICAL_ALERT]);
903
+ expect(result.createBy.data.incidentSeverities).toEqual([]);
904
+ },
905
+ );
906
+
907
+ test("a stray option is dropped, not validated: a foreign severity on a method rule is not an error", async () => {
153
908
  await expect(
154
- onBeforeCreate(buildCreateBy({ teamId: TEAM_ID, ruleType: RULE_TYPE })),
155
- ).rejects.toThrow(BadDataException);
909
+ create({
910
+ ruleType: ComplianceRuleType.HasNotificationPushMethod,
911
+ incidentSeverities: [FOREIGN_INCIDENT],
912
+ alertSeverities: ["not-a-uuid"],
913
+ }),
914
+ ).resolves.toBeDefined();
156
915
 
916
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
917
+ expect(alertSeverityCountBy).not.toHaveBeenCalled();
918
+ });
919
+
920
+ test("a stray severity of the other kind is dropped, not validated", async () => {
157
921
  await expect(
158
- onBeforeCreate(
159
- buildCreateBy({ teamId: OTHER_TEAM_ID, ruleType: RULE_TYPE }),
160
- ),
922
+ create({
923
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
924
+ alertSeverities: [FOREIGN_ALERT],
925
+ }),
161
926
  ).resolves.toBeDefined();
162
927
 
163
- expect(findSpy).toHaveBeenCalledTimes(2);
164
- });
165
-
166
- test("a different rule type on the same team is allowed", async () => {
167
- const findSpy: jest.SpyInstance = jest
168
- .spyOn(TeamComplianceSettingService, "findOneBy")
169
- .mockImplementation(((
170
- args: Record<string, unknown>,
171
- ): Promise<unknown> => {
172
- const query: Record<string, unknown> = args["query"] as Record<
173
- string,
174
- unknown
175
- >;
176
- if (query["ruleType"] === RULE_TYPE) {
177
- const existing: TeamComplianceSetting = new TeamComplianceSetting();
178
- existing._id = "33333333-3333-4333-8333-333333333333";
179
- return Promise.resolve(existing);
180
- }
181
- return Promise.resolve(null);
182
- }) as never);
928
+ expect(alertSeverityCountBy).not.toHaveBeenCalled();
929
+ });
930
+
931
+ test("options that were not sent are left unset rather than invented", async () => {
932
+ const result: OnCreate<TeamComplianceSetting> = await create({
933
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
934
+ });
935
+
936
+ expect(result.createBy.data.notificationChannel).toBeUndefined();
937
+ expect(result.createBy.data.incidentSeverities).toBeUndefined();
938
+ expect(result.createBy.data.alertSeverities).toBeUndefined();
939
+ });
940
+ });
941
+
942
+ describe("TeamComplianceSettingService onBeforeCreate - severities must belong to the project", () => {
943
+ test.each(INCIDENT_RULE_TYPES)(
944
+ "%s counts its incident severities in the rule's project, once, as root",
945
+ async (ruleType: ComplianceRuleType) => {
946
+ await create({
947
+ ruleType: ruleType,
948
+ incidentSeverities: [MAJOR_INCIDENT, CRITICAL_INCIDENT],
949
+ });
950
+
951
+ expect(incidentSeverityCountBy).toHaveBeenCalledTimes(1);
952
+ expect(alertSeverityCountBy).not.toHaveBeenCalled();
953
+
954
+ const countBy: JSONObject = countCalls(incidentSeverityCountBy)[0]!;
955
+ expect(includedIds(countBy["query"] as JSONObject)).toEqual(
956
+ [CRITICAL_INCIDENT, MAJOR_INCIDENT].sort(),
957
+ );
958
+ expect((countBy["query"] as JSONObject)["projectId"]).toBe(PROJECT_ID);
959
+ expect(Object.keys(countBy["query"] as JSONObject).sort()).toEqual([
960
+ "_id",
961
+ "projectId",
962
+ ]);
963
+ expect(countBy["props"]).toEqual({ isRoot: true });
964
+ },
965
+ );
966
+
967
+ test.each(ALERT_RULE_TYPES)(
968
+ "%s counts its alert severities in the rule's project, once, as root",
969
+ async (ruleType: ComplianceRuleType) => {
970
+ await create({
971
+ ruleType: ruleType,
972
+ alertSeverities: [CRITICAL_ALERT, MAJOR_ALERT],
973
+ });
974
+
975
+ expect(alertSeverityCountBy).toHaveBeenCalledTimes(1);
976
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
977
+
978
+ const countBy: JSONObject = countCalls(alertSeverityCountBy)[0]!;
979
+ expect(includedIds(countBy["query"] as JSONObject)).toEqual(
980
+ [CRITICAL_ALERT, MAJOR_ALERT].sort(),
981
+ );
982
+ expect((countBy["query"] as JSONObject)["projectId"]).toBe(PROJECT_ID);
983
+ expect(countBy["props"]).toEqual({ isRoot: true });
984
+ },
985
+ );
986
+
987
+ test.each(INCIDENT_RULE_TYPES)(
988
+ "%s refuses an incident severity from another project",
989
+ async (ruleType: ComplianceRuleType) => {
990
+ await expect(
991
+ create({
992
+ ruleType: ruleType,
993
+ incidentSeverities: [CRITICAL_INCIDENT, FOREIGN_INCIDENT],
994
+ }),
995
+ ).rejects.toThrow(
996
+ new BadDataException(FOREIGN_INCIDENT_SEVERITY_MESSAGE),
997
+ );
998
+
999
+ // Refused before the duplicate lookup.
1000
+ expect(settingsFindBy).not.toHaveBeenCalled();
1001
+ },
1002
+ );
1003
+
1004
+ test.each(ALERT_RULE_TYPES)(
1005
+ "%s refuses an alert severity from another project",
1006
+ async (ruleType: ComplianceRuleType) => {
1007
+ await expect(
1008
+ create({
1009
+ ruleType: ruleType,
1010
+ alertSeverities: [FOREIGN_ALERT],
1011
+ }),
1012
+ ).rejects.toThrow(new BadDataException(FOREIGN_ALERT_SEVERITY_MESSAGE));
1013
+ },
1014
+ );
183
1015
 
1016
+ test("an alert severity id is not an incident severity: the kinds are checked against their own table", async () => {
184
1017
  await expect(
185
- onBeforeCreate(
186
- buildCreateBy({ teamId: TEAM_ID, ruleType: "RequirePhoneNumber" }),
1018
+ create({
1019
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1020
+ incidentSeverities: [CRITICAL_ALERT],
1021
+ }),
1022
+ ).rejects.toThrow(FOREIGN_INCIDENT_SEVERITY_MESSAGE);
1023
+
1024
+ expect(alertSeverityCountBy).not.toHaveBeenCalled();
1025
+ });
1026
+
1027
+ test("refuses an id that is not a uuid, without querying with it", async () => {
1028
+ await expect(
1029
+ create({
1030
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1031
+ incidentSeverities: [CRITICAL_INCIDENT, "not-a-uuid"],
1032
+ }),
1033
+ ).rejects.toThrow(/Invalid ID format: "not-a-uuid"/);
1034
+
1035
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
1036
+ expect(settingsFindBy).not.toHaveBeenCalled();
1037
+ });
1038
+
1039
+ test("a repeated severity is one severity, and is counted once", async () => {
1040
+ await expect(
1041
+ create({
1042
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1043
+ incidentSeverities: [
1044
+ CRITICAL_INCIDENT,
1045
+ CRITICAL_INCIDENT,
1046
+ CRITICAL_INCIDENT.toUpperCase(),
1047
+ ],
1048
+ }),
1049
+ ).resolves.toBeDefined();
1050
+
1051
+ expect(
1052
+ includedIds(
1053
+ countCalls(incidentSeverityCountBy)[0]!["query"] as JSONObject,
1054
+ ),
1055
+ ).toEqual([CRITICAL_INCIDENT]);
1056
+ });
1057
+
1058
+ test("every shape a relation value arrives in is understood", async () => {
1059
+ const asModel: IncidentSeverity = new IncidentSeverity();
1060
+ asModel._id = MINOR_INCIDENT;
1061
+
1062
+ await create({
1063
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1064
+ incidentSeverities: [
1065
+ CRITICAL_INCIDENT,
1066
+ new ObjectID(MAJOR_INCIDENT),
1067
+ { _id: MINOR_INCIDENT },
1068
+ { id: CRITICAL_INCIDENT },
1069
+ asModel,
1070
+ ],
1071
+ });
1072
+
1073
+ expect(
1074
+ includedIds(
1075
+ countCalls(incidentSeverityCountBy)[0]!["query"] as JSONObject,
187
1076
  ),
1077
+ ).toEqual([CRITICAL_INCIDENT, MAJOR_INCIDENT, MINOR_INCIDENT].sort());
1078
+ });
1079
+
1080
+ test.each([
1081
+ ["not sent", undefined],
1082
+ ["null", null],
1083
+ ["empty", []],
1084
+ ])(
1085
+ "severities %s mean every severity, and read no severity table",
1086
+ async (_label: string, severities: unknown) => {
1087
+ await create({
1088
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1089
+ incidentSeverities: severities,
1090
+ });
1091
+
1092
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
1093
+ expect(alertSeverityCountBy).not.toHaveBeenCalled();
1094
+ },
1095
+ );
1096
+ });
1097
+
1098
+ describe("TeamComplianceSettingService onBeforeCreate - exact duplicates are refused", () => {
1099
+ test("the duplicate lookup reads the team's rules of that type in the project, as root", async () => {
1100
+ await create({
1101
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1102
+ notificationChannel: ComplianceNotificationChannel.Call,
1103
+ });
1104
+
1105
+ expect(settingsFindBy).toHaveBeenCalledTimes(1);
1106
+
1107
+ const findBy: JSONObject = findByCalls()[0]!;
1108
+ expect(findBy["query"]).toEqual({
1109
+ teamId: TEAM_ID,
1110
+ projectId: PROJECT_ID,
1111
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1112
+ });
1113
+ expect(findBy["select"]).toEqual({
1114
+ _id: true,
1115
+ ruleType: true,
1116
+ notificationChannel: true,
1117
+ // A rule a severity delete emptied is no duplicate: see below.
1118
+ options: true,
1119
+ incidentSeverities: { _id: true },
1120
+ alertSeverities: { _id: true },
1121
+ });
1122
+ expect(findBy["limit"]).toBe(LIMIT_PER_PROJECT);
1123
+ expect(findBy["skip"]).toBe(0);
1124
+ // The existing row may be one the caller cannot see.
1125
+ expect(findBy["props"]).toEqual({ isRoot: true });
1126
+ });
1127
+
1128
+ test("the message tells the user what to do instead", () => {
1129
+ expect(DUPLICATE_COMPLIANCE_RULE_MESSAGE).toBe(
1130
+ "This team already has a compliance rule that checks exactly the same thing. Edit that rule instead of adding another.",
1131
+ );
1132
+ });
1133
+
1134
+ test.each<[string, StoredRuleInput, NewRuleInput]>([
1135
+ [
1136
+ "the same channel and severities",
1137
+ {
1138
+ id: SIBLING_SETTING_ID,
1139
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1140
+ notificationChannel: ComplianceNotificationChannel.Call,
1141
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1142
+ },
1143
+ {
1144
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1145
+ notificationChannel: ComplianceNotificationChannel.Call,
1146
+ incidentSeverities: [CRITICAL_INCIDENT],
1147
+ },
1148
+ ],
1149
+ [
1150
+ "the same severities in another order, with repeats",
1151
+ {
1152
+ id: SIBLING_SETTING_ID,
1153
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1154
+ notificationChannel: ComplianceNotificationChannel.Call,
1155
+ incidentSeverityIds: [MAJOR_INCIDENT, CRITICAL_INCIDENT],
1156
+ },
1157
+ {
1158
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1159
+ notificationChannel: ComplianceNotificationChannel.Call,
1160
+ incidentSeverities: [
1161
+ { _id: CRITICAL_INCIDENT },
1162
+ { _id: MAJOR_INCIDENT },
1163
+ { _id: CRITICAL_INCIDENT },
1164
+ ],
1165
+ },
1166
+ ],
1167
+ [
1168
+ "the same severity ids in upper case",
1169
+ {
1170
+ id: SIBLING_SETTING_ID,
1171
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
1172
+ notificationChannel: ComplianceNotificationChannel.Push,
1173
+ alertSeverityIds: [CRITICAL_ALERT],
1174
+ },
1175
+ {
1176
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
1177
+ notificationChannel: ComplianceNotificationChannel.Push,
1178
+ alertSeverities: [CRITICAL_ALERT.toUpperCase()],
1179
+ },
1180
+ ],
1181
+ [
1182
+ "a legacy rule: no channel, every severity",
1183
+ {
1184
+ id: SIBLING_SETTING_ID,
1185
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1186
+ },
1187
+ {
1188
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1189
+ },
1190
+ ],
1191
+ [
1192
+ "a legacy rule, with the options sent explicitly empty",
1193
+ {
1194
+ id: SIBLING_SETTING_ID,
1195
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
1196
+ },
1197
+ {
1198
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
1199
+ notificationChannel: null,
1200
+ alertSeverities: [],
1201
+ },
1202
+ ],
1203
+ [
1204
+ "a legacy method rule",
1205
+ {
1206
+ id: SIBLING_SETTING_ID,
1207
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
1208
+ },
1209
+ {
1210
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
1211
+ },
1212
+ ],
1213
+ [
1214
+ "a method rule sent with a stray channel and severities, which it does not use",
1215
+ {
1216
+ id: SIBLING_SETTING_ID,
1217
+ ruleType: ComplianceRuleType.HasNotificationCallMethod,
1218
+ },
1219
+ {
1220
+ ruleType: ComplianceRuleType.HasNotificationCallMethod,
1221
+ notificationChannel: ComplianceNotificationChannel.Call,
1222
+ incidentSeverities: [CRITICAL_INCIDENT],
1223
+ alertSeverities: [CRITICAL_ALERT],
1224
+ },
1225
+ ],
1226
+ [
1227
+ "a stored rule carrying stray options of the other kind, which never counted",
1228
+ {
1229
+ id: SIBLING_SETTING_ID,
1230
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1231
+ alertSeverityIds: [CRITICAL_ALERT],
1232
+ },
1233
+ {
1234
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1235
+ },
1236
+ ],
1237
+ [
1238
+ "a stored rule whose channel is garbage, which reads as 'any channel'",
1239
+ {
1240
+ id: SIBLING_SETTING_ID,
1241
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1242
+ notificationChannel: "Pager",
1243
+ },
1244
+ {
1245
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1246
+ },
1247
+ ],
1248
+ [
1249
+ "the same episode rule",
1250
+ {
1251
+ id: SIBLING_SETTING_ID,
1252
+ ruleType: ComplianceRuleType.HasAlertEpisodeOnCallRules,
1253
+ notificationChannel: ComplianceNotificationChannel.Webhook,
1254
+ alertSeverityIds: [MAJOR_ALERT, CRITICAL_ALERT],
1255
+ },
1256
+ {
1257
+ ruleType: ComplianceRuleType.HasAlertEpisodeOnCallRules,
1258
+ notificationChannel: ComplianceNotificationChannel.Webhook,
1259
+ alertSeverities: [CRITICAL_ALERT, MAJOR_ALERT],
1260
+ },
1261
+ ],
1262
+ ])(
1263
+ "refuses %s",
1264
+ async (
1265
+ _label: string,
1266
+ existing: StoredRuleInput,
1267
+ incoming: NewRuleInput,
1268
+ ) => {
1269
+ store(existing);
1270
+
1271
+ await expect(create(incoming)).rejects.toThrow(
1272
+ new BadDataException(DUPLICATE_COMPLIANCE_RULE_MESSAGE),
1273
+ );
1274
+ },
1275
+ );
1276
+
1277
+ test.each<[string, StoredRuleInput, NewRuleInput]>([
1278
+ [
1279
+ "another channel for the same severities",
1280
+ {
1281
+ id: SIBLING_SETTING_ID,
1282
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1283
+ notificationChannel: ComplianceNotificationChannel.Call,
1284
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1285
+ },
1286
+ {
1287
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1288
+ notificationChannel: ComplianceNotificationChannel.Push,
1289
+ incidentSeverities: [CRITICAL_INCIDENT],
1290
+ },
1291
+ ],
1292
+ [
1293
+ "'any channel' next to a Call rule",
1294
+ {
1295
+ id: SIBLING_SETTING_ID,
1296
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1297
+ notificationChannel: ComplianceNotificationChannel.Call,
1298
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1299
+ },
1300
+ {
1301
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1302
+ notificationChannel: null,
1303
+ incidentSeverities: [CRITICAL_INCIDENT],
1304
+ },
1305
+ ],
1306
+ [
1307
+ "a Call rule next to a legacy 'any channel' rule",
1308
+ {
1309
+ id: SIBLING_SETTING_ID,
1310
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1311
+ },
1312
+ {
1313
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1314
+ notificationChannel: ComplianceNotificationChannel.Call,
1315
+ },
1316
+ ],
1317
+ [
1318
+ "a wider severity set",
1319
+ {
1320
+ id: SIBLING_SETTING_ID,
1321
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1322
+ notificationChannel: ComplianceNotificationChannel.Call,
1323
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1324
+ },
1325
+ {
1326
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1327
+ notificationChannel: ComplianceNotificationChannel.Call,
1328
+ incidentSeverities: [CRITICAL_INCIDENT, MAJOR_INCIDENT],
1329
+ },
1330
+ ],
1331
+ [
1332
+ "every severity next to one severity",
1333
+ {
1334
+ id: SIBLING_SETTING_ID,
1335
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
1336
+ notificationChannel: ComplianceNotificationChannel.Push,
1337
+ alertSeverityIds: [CRITICAL_ALERT],
1338
+ },
1339
+ {
1340
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
1341
+ notificationChannel: ComplianceNotificationChannel.Push,
1342
+ },
1343
+ ],
1344
+ [
1345
+ "the same scope on the episode rule type",
1346
+ {
1347
+ id: SIBLING_SETTING_ID,
1348
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1349
+ notificationChannel: ComplianceNotificationChannel.Call,
1350
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1351
+ },
1352
+ {
1353
+ ruleType: ComplianceRuleType.HasIncidentEpisodeOnCallRules,
1354
+ notificationChannel: ComplianceNotificationChannel.Call,
1355
+ incidentSeverities: [CRITICAL_INCIDENT],
1356
+ },
1357
+ ],
1358
+ [
1359
+ "the same rule on another team",
1360
+ {
1361
+ id: SIBLING_SETTING_ID,
1362
+ teamId: OTHER_TEAM_ID,
1363
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
1364
+ },
1365
+ {
1366
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
1367
+ },
1368
+ ],
1369
+ [
1370
+ "another method rule",
1371
+ {
1372
+ id: SIBLING_SETTING_ID,
1373
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
1374
+ },
1375
+ {
1376
+ ruleType: ComplianceRuleType.HasNotificationSMSMethod,
1377
+ },
1378
+ ],
1379
+ ])(
1380
+ "allows %s",
1381
+ async (
1382
+ _label: string,
1383
+ existing: StoredRuleInput,
1384
+ incoming: NewRuleInput,
1385
+ ) => {
1386
+ store(existing);
1387
+
1388
+ await expect(create(incoming)).resolves.toBeDefined();
1389
+ },
1390
+ );
1391
+
1392
+ test("a team with the same team id in another project does not block the rule", async () => {
1393
+ store({
1394
+ id: OTHER_PROJECT_SETTING_ID,
1395
+ projectId: OTHER_PROJECT_ID,
1396
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
1397
+ });
1398
+
1399
+ await expect(
1400
+ create({ ruleType: ComplianceRuleType.HasNotificationEmailMethod }),
188
1401
  ).resolves.toBeDefined();
189
- expect(findSpy).toHaveBeenCalledTimes(1);
1402
+ });
1403
+
1404
+ test("several rules of one type can live side by side, and only the exact one is refused", async () => {
1405
+ store(
1406
+ {
1407
+ id: "66666666-6666-4666-8666-00000000000a",
1408
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1409
+ notificationChannel: ComplianceNotificationChannel.Call,
1410
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1411
+ },
1412
+ {
1413
+ id: "66666666-6666-4666-8666-00000000000b",
1414
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1415
+ notificationChannel: ComplianceNotificationChannel.Push,
1416
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1417
+ },
1418
+ {
1419
+ id: "66666666-6666-4666-8666-00000000000c",
1420
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1421
+ },
1422
+ );
1423
+
1424
+ await expect(
1425
+ create({
1426
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1427
+ notificationChannel: ComplianceNotificationChannel.SMS,
1428
+ incidentSeverities: [CRITICAL_INCIDENT],
1429
+ }),
1430
+ ).resolves.toBeDefined();
1431
+
1432
+ await expect(
1433
+ create({
1434
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1435
+ notificationChannel: ComplianceNotificationChannel.Push,
1436
+ incidentSeverities: [CRITICAL_INCIDENT],
1437
+ }),
1438
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
1439
+ });
1440
+ });
1441
+
1442
+ /*
1443
+ * ---------------------------------------------------------------------------
1444
+ * update
1445
+ * ---------------------------------------------------------------------------
1446
+ */
1447
+
1448
+ const INCIDENT_CALL_CRITICAL: StoredRuleInput = {
1449
+ id: SETTING_ID,
1450
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1451
+ notificationChannel: ComplianceNotificationChannel.Call,
1452
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1453
+ };
1454
+
1455
+ /*
1456
+ * "Call for Critical incidents" after Critical was deleted: the join row
1457
+ * cascaded away, and pauseRulesLeftWithoutSeverities paused and marked it.
1458
+ */
1459
+ const EMPTIED_BY_DELETE: StoredRuleInput = {
1460
+ id: SETTING_ID,
1461
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1462
+ notificationChannel: ComplianceNotificationChannel.Call,
1463
+ enabled: false,
1464
+ options: { [SEVERITIES_DELETED_OPTION]: true },
1465
+ };
1466
+
1467
+ describe("TeamComplianceSettingService onBeforeUpdate - updates that do not touch the scope", () => {
1468
+ test.each([
1469
+ ["switching a rule off", { enabled: false }],
1470
+ ["switching a paused rule off again", { enabled: false, options: {} }],
1471
+ ["editing the unused options JSON", { options: { note: "x" } }],
1472
+ ])(
1473
+ "%s reads nothing and passes the update through untouched",
1474
+ async (_label: string, data: Record<string, unknown>) => {
1475
+ store(INCIDENT_CALL_CRITICAL);
1476
+
1477
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1478
+ ...data,
1479
+ });
1480
+
1481
+ const result: OnUpdate<TeamComplianceSetting> =
1482
+ await onBeforeUpdate(updateBy);
1483
+
1484
+ expect(result.updateBy).toBe(updateBy);
1485
+ expect(result.carryForward).toBeNull();
1486
+ expect(dataOf(result.updateBy)).toEqual(data);
1487
+ expectNoReads();
1488
+ },
1489
+ );
1490
+
1491
+ test("a disable never fails because of the rule's configuration - even on a rule whose stored type is garbage", async () => {
1492
+ store({ id: SETTING_ID, ruleType: "RequireTwoFactorAuth" });
1493
+
1494
+ await expect(update({ enabled: false })).resolves.toBeDefined();
1495
+ expectNoReads();
1496
+ });
1497
+
1498
+ test("a disable never fails on a rule a severity delete emptied either, and reads nothing", async () => {
1499
+ store({ ...EMPTIED_BY_DELETE });
1500
+
1501
+ await expect(update({ enabled: false })).resolves.toBeDefined();
1502
+ expectNoReads();
1503
+ });
1504
+ });
1505
+
1506
+ /*
1507
+ * ---------------------------------------------------------------------------
1508
+ * Rules a severity delete left with nothing to check.
1509
+ * ---------------------------------------------------------------------------
1510
+ *
1511
+ * pauseRulesLeftWithoutSeverities pauses such a rule and marks it
1512
+ * (options.severitiesDeleted). With no severities left it is stored exactly
1513
+ * like a rule for EVERY severity of its kind, so turned back on as it is it
1514
+ * would check every severity - and it would look like a duplicate of the
1515
+ * team's real "every severity" rule of the same type and channel.
1516
+ */
1517
+
1518
+ describe("TeamComplianceSettingService onBeforeUpdate - turning a rule on", () => {
1519
+ test("a rule a severity delete emptied cannot be turned back on as it is", async () => {
1520
+ store({ ...EMPTIED_BY_DELETE });
1521
+
1522
+ await expect(update({ enabled: true })).rejects.toThrow(
1523
+ new BadDataException(SEVERITIES_DELETED_ENABLE_MESSAGE),
1524
+ );
1525
+ expect(SEVERITIES_DELETED_ENABLE_MESSAGE).toBe(
1526
+ "Every severity this rule was scoped to has been deleted. Edit the rule to choose new severities before turning it back on.",
1527
+ );
1528
+ });
1529
+
1530
+ test("an ordinary rule - scoped, or for every severity - is turned on", async () => {
1531
+ store(INCIDENT_CALL_CRITICAL, {
1532
+ id: SIBLING_SETTING_ID,
1533
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1534
+ notificationChannel: ComplianceNotificationChannel.Call,
1535
+ enabled: false,
1536
+ });
1537
+
1538
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1539
+ enabled: true,
1540
+ });
1541
+
1542
+ await expect(onBeforeUpdate(updateBy)).resolves.toBeDefined();
1543
+ expect(dataOf(updateBy)).toEqual({ enabled: true });
1544
+
1545
+ await expect(
1546
+ onBeforeUpdate(
1547
+ updateByFor({ enabled: true }, { query: { _id: SIBLING_SETTING_ID } }),
1548
+ ),
1549
+ ).resolves.toBeDefined();
1550
+ });
1551
+
1552
+ test("reads only the rows being turned on, inside the caller's project, as root, with what the check needs", async () => {
1553
+ store(INCIDENT_CALL_CRITICAL);
1554
+
1555
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1556
+ enabled: true,
1557
+ });
1558
+
1559
+ await onBeforeUpdate(updateBy);
1560
+
1561
+ expect(settingsFindBy).toHaveBeenCalledTimes(1);
1562
+
1563
+ const read: JSONObject = findByCalls()[0]!;
1564
+ expect(read["query"]).toEqual({ _id: SETTING_ID, projectId: PROJECT_ID });
1565
+ expect(read["select"]).toEqual({
1566
+ _id: true,
1567
+ ruleType: true,
1568
+ options: true,
1569
+ incidentSeverities: { _id: true },
1570
+ alertSeverities: { _id: true },
1571
+ });
1572
+ expect(read["limit"]).toBe(LIMIT_PER_PROJECT);
1573
+ expect(read["props"]).toEqual({ isRoot: true });
1574
+ // The caller's own query object is left alone.
1575
+ expect(updateBy.query).toEqual({ _id: SETTING_ID });
1576
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
1577
+ });
1578
+
1579
+ test("a marked rule in another project is not read, so it does not block the caller", async () => {
1580
+ store({
1581
+ ...EMPTIED_BY_DELETE,
1582
+ projectId: OTHER_PROJECT_ID,
1583
+ teamId: OTHER_TEAM_ID,
1584
+ });
1585
+
1586
+ await expect(update({ enabled: true })).resolves.toBeDefined();
1587
+ });
1588
+
1589
+ test("root is held to it too: nothing may turn such a rule into an every-severity rule", async () => {
1590
+ store({ ...EMPTIED_BY_DELETE });
1591
+
1592
+ await expect(
1593
+ onBeforeUpdate(
1594
+ updateByFor({ enabled: true }, { props: { isRoot: true } }),
1595
+ ),
1596
+ ).rejects.toThrow(SEVERITIES_DELETED_ENABLE_MESSAGE);
1597
+ });
1598
+
1599
+ test("a bulk switch-on is refused when any row it matches was emptied", async () => {
1600
+ store(INCIDENT_CALL_CRITICAL, {
1601
+ ...EMPTIED_BY_DELETE,
1602
+ id: SIBLING_SETTING_ID,
1603
+ });
1604
+
1605
+ await expect(
1606
+ onBeforeUpdate(
1607
+ updateByFor(
1608
+ { enabled: true },
1609
+ { query: { teamId: TEAM_ID }, props: { isRoot: true } },
1610
+ ),
1611
+ ),
1612
+ ).rejects.toThrow(SEVERITIES_DELETED_ENABLE_MESSAGE);
1613
+ });
1614
+
1615
+ test.each<[string, StoredRuleInput]>([
1616
+ [
1617
+ "a stale mark on a rule that has severities again",
1618
+ { ...EMPTIED_BY_DELETE, incidentSeverityIds: [MAJOR_INCIDENT] },
1619
+ ],
1620
+ [
1621
+ "a mark on a method rule, which has no severities to lose",
1622
+ {
1623
+ id: SETTING_ID,
1624
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
1625
+ enabled: false,
1626
+ options: { [SEVERITIES_DELETED_OPTION]: true },
1627
+ },
1628
+ ],
1629
+ [
1630
+ "a mark that is not `true`",
1631
+ { ...EMPTIED_BY_DELETE, options: { [SEVERITIES_DELETED_OPTION]: "yes" } },
1632
+ ],
1633
+ ])(
1634
+ "%s does not block turning the rule on",
1635
+ async (_label: string, row: StoredRuleInput) => {
1636
+ store(row);
1637
+
1638
+ await expect(update({ enabled: true })).resolves.toBeDefined();
1639
+ },
1640
+ );
1641
+
1642
+ test("an alert rule emptied by an alert severity delete is held to it too", async () => {
1643
+ store({
1644
+ id: SETTING_ID,
1645
+ ruleType: ComplianceRuleType.HasAlertEpisodeOnCallRules,
1646
+ notificationChannel: ComplianceNotificationChannel.Push,
1647
+ enabled: false,
1648
+ options: { [SEVERITIES_DELETED_OPTION]: true },
1649
+ // A stray severity of the other kind is not a scope.
1650
+ incidentSeverityIds: [CRITICAL_INCIDENT],
1651
+ });
1652
+
1653
+ await expect(update({ enabled: true })).rejects.toThrow(
1654
+ SEVERITIES_DELETED_ENABLE_MESSAGE,
1655
+ );
1656
+ });
1657
+ });
1658
+
1659
+ describe("TeamComplianceSettingService onBeforeUpdate - re-scoping a rule a severity delete emptied", () => {
1660
+ test("the edit form's save - new severities, and enabled - is accepted and clears the mark in the same write", async () => {
1661
+ store({ ...EMPTIED_BY_DELETE });
1662
+
1663
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1664
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1665
+ notificationChannel: ComplianceNotificationChannel.Call,
1666
+ incidentSeverities: [{ _id: MAJOR_INCIDENT }],
1667
+ alertSeverities: [],
1668
+ enabled: true,
1669
+ });
1670
+
1671
+ await expect(onBeforeUpdate(updateBy)).resolves.toBeDefined();
1672
+
1673
+ expect(dataOf(updateBy)["enabled"]).toBe(true);
1674
+ expect(dataOf(updateBy)["options"]).toBeNull();
1675
+ expect(
1676
+ writtenIds(dataOf(updateBy)["incidentSeverities"], IncidentSeverity),
1677
+ ).toEqual([MAJOR_INCIDENT]);
1678
+ });
1679
+
1680
+ test.each<[string, Record<string, unknown>]>([
1681
+ ["new severities", { incidentSeverities: [MAJOR_INCIDENT] }],
1682
+ [
1683
+ "deliberately every severity, from the edit form",
1684
+ { incidentSeverities: [], enabled: true },
1685
+ ],
1686
+ [
1687
+ "a new channel",
1688
+ { notificationChannel: ComplianceNotificationChannel.Push },
1689
+ ],
1690
+ [
1691
+ "a new rule type",
1692
+ { ruleType: ComplianceRuleType.HasNotificationEmailMethod },
1693
+ ],
1694
+ ])(
1695
+ "%s clears the mark",
1696
+ async (_label: string, data: Record<string, unknown>) => {
1697
+ store({ ...EMPTIED_BY_DELETE });
1698
+
1699
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1700
+ ...data,
1701
+ });
1702
+
1703
+ await onBeforeUpdate(updateBy);
1704
+
1705
+ expect(dataOf(updateBy)["options"]).toBeNull();
1706
+ },
1707
+ );
1708
+
1709
+ test("every other option the rule carries is kept", async () => {
1710
+ store({
1711
+ ...EMPTIED_BY_DELETE,
1712
+ options: { [SEVERITIES_DELETED_OPTION]: true, note: "keep me" },
1713
+ });
1714
+
1715
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1716
+ incidentSeverities: [MAJOR_INCIDENT],
1717
+ });
1718
+
1719
+ await onBeforeUpdate(updateBy);
1720
+
1721
+ expect(dataOf(updateBy)["options"]).toEqual({ note: "keep me" });
1722
+ });
1723
+
1724
+ test("options sent with the re-scope are written, without the mark", async () => {
1725
+ store({ ...EMPTIED_BY_DELETE });
1726
+
1727
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1728
+ incidentSeverities: [MAJOR_INCIDENT],
1729
+ options: { [SEVERITIES_DELETED_OPTION]: true, note: "sent" },
1730
+ });
1731
+
1732
+ await onBeforeUpdate(updateBy);
1733
+
1734
+ expect(dataOf(updateBy)["options"]).toEqual({ note: "sent" });
1735
+ });
1736
+
1737
+ test("a re-scope of an unmarked rule leaves its options alone", async () => {
1738
+ store({ ...INCIDENT_CALL_CRITICAL, options: { note: "untouched" } });
1739
+
1740
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1741
+ incidentSeverities: [MAJOR_INCIDENT],
1742
+ });
1743
+
1744
+ await onBeforeUpdate(updateBy);
1745
+
1746
+ expect("options" in dataOf(updateBy)).toBe(false);
1747
+ });
1748
+
1749
+ test("a re-scope over rows that disagree on their options is refused rather than overwrite one row's options with another's", async () => {
1750
+ store(
1751
+ { ...EMPTIED_BY_DELETE },
1752
+ {
1753
+ ...INCIDENT_CALL_CRITICAL,
1754
+ id: SIBLING_SETTING_ID,
1755
+ notificationChannel: ComplianceNotificationChannel.SMS,
1756
+ options: { note: "the sibling's own" },
1757
+ },
1758
+ );
1759
+
1760
+ await expect(
1761
+ onBeforeUpdate(
1762
+ updateByFor(
1763
+ { incidentSeverities: [MINOR_INCIDENT] },
1764
+ { query: { teamId: TEAM_ID }, props: { isRoot: true } },
1765
+ ),
1766
+ ),
1767
+ ).rejects.toThrow(new BadDataException(OPTIONS_DIFFER_MESSAGE));
1768
+ });
1769
+
1770
+ test("the re-scoped rule is still checked for duplicates, and refused like any other", async () => {
1771
+ store(
1772
+ { ...EMPTIED_BY_DELETE },
1773
+ {
1774
+ ...INCIDENT_CALL_CRITICAL,
1775
+ id: SIBLING_SETTING_ID,
1776
+ incidentSeverityIds: [MAJOR_INCIDENT],
1777
+ },
1778
+ );
1779
+
1780
+ await expect(
1781
+ update({ incidentSeverities: [MAJOR_INCIDENT], enabled: true }),
1782
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
1783
+ });
1784
+ });
1785
+
1786
+ describe("TeamComplianceSettingService - a rule a severity delete emptied duplicates nothing", () => {
1787
+ test("the team's real 'every severity' rule of the same type and channel can still be saved from its edit form", async () => {
1788
+ // What the delete left: no severities, paused, marked.
1789
+ store(
1790
+ { ...EMPTIED_BY_DELETE, id: SIBLING_SETTING_ID },
1791
+ {
1792
+ id: SETTING_ID,
1793
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1794
+ notificationChannel: ComplianceNotificationChannel.Call,
1795
+ },
1796
+ );
1797
+
1798
+ await expect(
1799
+ update({
1800
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1801
+ notificationChannel: ComplianceNotificationChannel.Call,
1802
+ incidentSeverities: [],
1803
+ alertSeverities: [],
1804
+ enabled: false,
1805
+ }),
1806
+ ).resolves.toBeDefined();
1807
+ });
1808
+
1809
+ test("a new 'every severity' rule of the same type and channel may be added beside it", async () => {
1810
+ store({ ...EMPTIED_BY_DELETE, id: SIBLING_SETTING_ID });
1811
+
1812
+ await expect(
1813
+ create({
1814
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1815
+ notificationChannel: ComplianceNotificationChannel.Call,
1816
+ }),
1817
+ ).resolves.toBeDefined();
1818
+ });
1819
+
1820
+ test("an unmarked rule with no severities - a real 'every severity' rule, paused or not - is still a duplicate", async () => {
1821
+ store({
1822
+ ...EMPTIED_BY_DELETE,
1823
+ id: SIBLING_SETTING_ID,
1824
+ options: undefined,
1825
+ });
1826
+
1827
+ await expect(
1828
+ create({
1829
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1830
+ notificationChannel: ComplianceNotificationChannel.Call,
1831
+ }),
1832
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
1833
+ });
1834
+
1835
+ test("a stale mark on a rule that has severities again does not hide it from the duplicate check", async () => {
1836
+ store({
1837
+ ...EMPTIED_BY_DELETE,
1838
+ id: SIBLING_SETTING_ID,
1839
+ incidentSeverityIds: [MAJOR_INCIDENT],
1840
+ });
1841
+
1842
+ await expect(
1843
+ create({
1844
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1845
+ notificationChannel: ComplianceNotificationChannel.Call,
1846
+ incidentSeverities: [MAJOR_INCIDENT],
1847
+ }),
1848
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
1849
+ });
1850
+ });
1851
+
1852
+ describe("TeamComplianceSettingService onBeforeUpdate - refusals before any read", () => {
1853
+ test.each([
1854
+ ["an unknown rule type", { ruleType: "RequireTwoFactorAuth" }],
1855
+ ["a null rule type", { ruleType: null }],
1856
+ ["an unknown channel", { notificationChannel: "Pager" }],
1857
+ ["a channel in the wrong case", { notificationChannel: "push" }],
1858
+ ])("refuses %s", async (_label: string, data: Record<string, unknown>) => {
1859
+ store(INCIDENT_CALL_CRITICAL);
1860
+
1861
+ await expect(update(data)).rejects.toThrow(BadDataException);
1862
+ expectNoReads();
1863
+ });
1864
+ });
1865
+
1866
+ describe("TeamComplianceSettingService onBeforeUpdate - scope changes", () => {
1867
+ test("reads the rows being changed inside the caller's project, as root, with what their scope needs", async () => {
1868
+ store(INCIDENT_CALL_CRITICAL);
1869
+
1870
+ await update({ notificationChannel: ComplianceNotificationChannel.Push });
1871
+
1872
+ const existingRead: JSONObject = findByCalls()[0]!;
1873
+ expect(existingRead["query"]).toEqual({
1874
+ _id: SETTING_ID,
1875
+ projectId: PROJECT_ID,
1876
+ });
1877
+ expect(existingRead["select"]).toEqual({
1878
+ _id: true,
1879
+ teamId: true,
1880
+ projectId: true,
1881
+ ruleType: true,
1882
+ notificationChannel: true,
1883
+ options: true,
1884
+ incidentSeverities: { _id: true },
1885
+ alertSeverities: { _id: true },
1886
+ });
1887
+ expect(existingRead["limit"]).toBe(LIMIT_PER_PROJECT);
1888
+ expect(existingRead["skip"]).toBe(0);
1889
+ expect(existingRead["props"]).toEqual({ isRoot: true });
1890
+ });
1891
+
1892
+ test("does not copy the caller's own query object when adding the project", async () => {
1893
+ store(INCIDENT_CALL_CRITICAL);
1894
+
1895
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
1896
+ notificationChannel: ComplianceNotificationChannel.Push,
1897
+ });
1898
+
1899
+ await onBeforeUpdate(updateBy);
1900
+
1901
+ expect(updateBy.query).toEqual({ _id: SETTING_ID });
1902
+ });
1903
+
1904
+ test("a rule in another project is invisible: nothing is checked, nothing leaks", async () => {
1905
+ store({
1906
+ ...INCIDENT_CALL_CRITICAL,
1907
+ id: OTHER_PROJECT_SETTING_ID,
1908
+ projectId: OTHER_PROJECT_ID,
1909
+ });
1910
+ // A rule in the caller's project that the other one would duplicate.
1911
+ store({
1912
+ ...INCIDENT_CALL_CRITICAL,
1913
+ id: SIBLING_SETTING_ID,
1914
+ notificationChannel: ComplianceNotificationChannel.Push,
1915
+ });
1916
+
1917
+ await expect(
1918
+ onBeforeUpdate(
1919
+ updateByFor(
1920
+ { notificationChannel: ComplianceNotificationChannel.Push },
1921
+ { query: { _id: OTHER_PROJECT_SETTING_ID } },
1922
+ ),
1923
+ ),
1924
+ ).resolves.toBeDefined();
1925
+
1926
+ expect(findByCalls()[0]?.["query"]).toEqual({
1927
+ _id: OTHER_PROJECT_SETTING_ID,
1928
+ projectId: PROJECT_ID,
1929
+ });
1930
+ expect(siblingReads()).toEqual([]);
1931
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
1932
+ });
1933
+
1934
+ test("a root update without a tenant reads by the update's own query", async () => {
1935
+ store(INCIDENT_CALL_CRITICAL);
1936
+
1937
+ await onBeforeUpdate(
1938
+ updateByFor(
1939
+ { notificationChannel: ComplianceNotificationChannel.Push },
1940
+ { props: { isRoot: true } },
1941
+ ),
1942
+ );
1943
+
1944
+ expect(findByCalls()[0]?.["query"]).toEqual({ _id: SETTING_ID });
1945
+ // The row's own project scopes the checks.
1946
+ expect(siblingReads()[0]?.["query"]).toMatchObject({
1947
+ teamId: TEAM_ID,
1948
+ projectId: PROJECT_ID,
1949
+ });
1950
+ });
1951
+
1952
+ test("compares against the team's other rules, never against the row itself", async () => {
1953
+ store(INCIDENT_CALL_CRITICAL);
1954
+
1955
+ await update({ notificationChannel: ComplianceNotificationChannel.Push });
1956
+
1957
+ const siblings: Array<JSONObject> = siblingReads();
1958
+ expect(siblings).toHaveLength(1);
1959
+
1960
+ const query: JSONObject = siblings[0]!["query"] as JSONObject;
1961
+ expect(String(query["teamId"])).toBe(TEAM_ID.toString());
1962
+ expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
1963
+ expect(query["ruleType"]).toBe(ComplianceRuleType.HasIncidentOnCallRules);
1964
+
1965
+ const excluded: unknown = query["_id"];
1966
+ expect(excluded).toBeInstanceOf(FindOperator);
1967
+ expect(
1968
+ Object.values(
1969
+ (excluded as FindOperator<unknown>).objectLiteralParameters || {},
1970
+ ),
1971
+ ).toEqual([SETTING_ID]);
1972
+ expect(siblings[0]!["props"]).toEqual({ isRoot: true });
1973
+ });
1974
+
1975
+ test("re-saving a rule unchanged - what the edit form does - is not a duplicate of itself", async () => {
1976
+ store(INCIDENT_CALL_CRITICAL);
1977
+
1978
+ await expect(
1979
+ update({
1980
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
1981
+ notificationChannel: ComplianceNotificationChannel.Call,
1982
+ incidentSeverities: [{ _id: CRITICAL_INCIDENT }],
1983
+ alertSeverities: [],
1984
+ enabled: true,
1985
+ }),
1986
+ ).resolves.toBeDefined();
1987
+ });
1988
+
1989
+ test("refuses a change that makes the rule identical to another rule of the team", async () => {
1990
+ store(INCIDENT_CALL_CRITICAL, {
1991
+ ...INCIDENT_CALL_CRITICAL,
1992
+ id: SIBLING_SETTING_ID,
1993
+ notificationChannel: ComplianceNotificationChannel.Push,
1994
+ });
1995
+
1996
+ await expect(
1997
+ update({ notificationChannel: ComplianceNotificationChannel.Push }),
1998
+ ).rejects.toThrow(new BadDataException(DUPLICATE_COMPLIANCE_RULE_MESSAGE));
1999
+ });
2000
+
2001
+ test("the scope is the update merged over what is stored: a severity change keeps the stored channel", async () => {
2002
+ store(INCIDENT_CALL_CRITICAL, {
2003
+ ...INCIDENT_CALL_CRITICAL,
2004
+ id: SIBLING_SETTING_ID,
2005
+ incidentSeverityIds: [MAJOR_INCIDENT],
2006
+ });
2007
+
2008
+ // Call + [Major] exists already; changing only the severities collides.
2009
+ await expect(
2010
+ update({ incidentSeverities: [MAJOR_INCIDENT] }),
2011
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
2012
+
2013
+ // Call + [Minor] does not.
2014
+ await expect(
2015
+ update({ incidentSeverities: [MINOR_INCIDENT] }),
2016
+ ).resolves.toBeDefined();
2017
+ });
2018
+
2019
+ test("a channel change keeps the stored severities", async () => {
2020
+ store(INCIDENT_CALL_CRITICAL, {
2021
+ id: SIBLING_SETTING_ID,
2022
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2023
+ notificationChannel: ComplianceNotificationChannel.SMS,
2024
+ incidentSeverityIds: [CRITICAL_INCIDENT],
2025
+ });
2026
+
2027
+ await expect(
2028
+ update({ notificationChannel: ComplianceNotificationChannel.SMS }),
2029
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
2030
+ });
2031
+
2032
+ test("clearing the channel back to 'any channel' is a scope change and is checked", async () => {
2033
+ store(INCIDENT_CALL_CRITICAL, {
2034
+ id: SIBLING_SETTING_ID,
2035
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2036
+ incidentSeverityIds: [CRITICAL_INCIDENT],
2037
+ });
2038
+
2039
+ await expect(update({ notificationChannel: null })).rejects.toThrow(
2040
+ DUPLICATE_COMPLIANCE_RULE_MESSAGE,
2041
+ );
2042
+ });
2043
+
2044
+ test("new severities are validated against the rule's project", async () => {
2045
+ store(INCIDENT_CALL_CRITICAL);
2046
+
2047
+ await expect(
2048
+ update({ incidentSeverities: [FOREIGN_INCIDENT] }),
2049
+ ).rejects.toThrow(new BadDataException(FOREIGN_INCIDENT_SEVERITY_MESSAGE));
2050
+
2051
+ expect(countCalls(incidentSeverityCountBy)[0]?.["query"]).toMatchObject({
2052
+ projectId: PROJECT_ID,
2053
+ });
2054
+ expect(siblingReads()).toEqual([]);
2055
+ });
2056
+
2057
+ test("an invalid severity id in an update is refused", async () => {
2058
+ store(INCIDENT_CALL_CRITICAL);
2059
+
2060
+ await expect(
2061
+ update({ incidentSeverities: ["not-a-uuid"] }),
2062
+ ).rejects.toThrow(/Invalid ID format/);
2063
+ });
2064
+
2065
+ test("a rule whose stored type is not recognised is skipped, not failed", async () => {
2066
+ store({
2067
+ id: SETTING_ID,
2068
+ ruleType: "RequireTwoFactorAuth",
2069
+ notificationChannel: ComplianceNotificationChannel.Call,
2070
+ });
2071
+
2072
+ await expect(
2073
+ update({ notificationChannel: ComplianceNotificationChannel.Push }),
2074
+ ).resolves.toBeDefined();
2075
+
2076
+ expect(siblingReads()).toEqual([]);
2077
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
2078
+ });
2079
+
2080
+ /*
2081
+ * The relation save writes a sent list to the row whatever its type, so
2082
+ * the list is checked by its own kind against the row's project even when
2083
+ * the row's type is one this build does not recognise.
2084
+ */
2085
+ test.each<[string, Record<string, unknown>, string]>([
2086
+ [
2087
+ "incident",
2088
+ { incidentSeverities: [{ _id: FOREIGN_INCIDENT }] },
2089
+ FOREIGN_INCIDENT_SEVERITY_MESSAGE,
2090
+ ],
2091
+ [
2092
+ "alert",
2093
+ { alertSeverities: [{ _id: FOREIGN_ALERT }] },
2094
+ FOREIGN_ALERT_SEVERITY_MESSAGE,
2095
+ ],
2096
+ ])(
2097
+ "another project's %s severity sent to a rule whose stored type is not recognised is refused",
2098
+ async (_label: string, data: Record<string, unknown>, message: string) => {
2099
+ store({ id: SETTING_ID, ruleType: "LegacyRule" });
2100
+
2101
+ await expect(update({ ...data })).rejects.toThrow(
2102
+ new BadDataException(message),
2103
+ );
2104
+ expect(siblingReads()).toEqual([]);
2105
+ },
2106
+ );
2107
+
2108
+ test("this project's severities sent to such a rule are checked against the row's project and accepted", async () => {
2109
+ store({ id: SETTING_ID, ruleType: "LegacyRule" });
2110
+
2111
+ await expect(
2112
+ update({
2113
+ incidentSeverities: [CRITICAL_INCIDENT],
2114
+ alertSeverities: [CRITICAL_ALERT],
2115
+ }),
2116
+ ).resolves.toBeDefined();
2117
+
2118
+ expect(
2119
+ countCalls(incidentSeverityCountBy).map((call: JSONObject) => {
2120
+ return [
2121
+ String((call["query"] as JSONObject)["projectId"]),
2122
+ includedIds(call["query"] as JSONObject),
2123
+ ];
2124
+ }),
2125
+ ).toEqual([[PROJECT_ID.toString(), [CRITICAL_INCIDENT]]]);
2126
+ expect(
2127
+ countCalls(alertSeverityCountBy).map((call: JSONObject) => {
2128
+ return includedIds(call["query"] as JSONObject);
2129
+ }),
2130
+ ).toEqual([[CRITICAL_ALERT]]);
2131
+ });
2132
+
2133
+ test("a root update over rows of two projects checks a sent list against each project, once per project", async () => {
2134
+ store(
2135
+ { id: SETTING_ID, ruleType: "LegacyRule" },
2136
+ {
2137
+ id: OTHER_PROJECT_SETTING_ID,
2138
+ ruleType: "LegacyRule",
2139
+ projectId: OTHER_PROJECT_ID,
2140
+ teamId: OTHER_TEAM_ID,
2141
+ },
2142
+ { id: SIBLING_SETTING_ID, ruleType: "LegacyRule" },
2143
+ );
2144
+
2145
+ await expect(
2146
+ onBeforeUpdate(
2147
+ updateByFor(
2148
+ { incidentSeverities: [CRITICAL_INCIDENT] },
2149
+ { query: { ruleType: "LegacyRule" }, props: { isRoot: true } },
2150
+ ),
2151
+ ),
2152
+ ).rejects.toThrow(FOREIGN_INCIDENT_SEVERITY_MESSAGE);
2153
+
2154
+ expect(
2155
+ countCalls(incidentSeverityCountBy).map((call: JSONObject): string => {
2156
+ return String((call["query"] as JSONObject)["projectId"]);
2157
+ }),
2158
+ ).toEqual([PROJECT_ID.toString(), OTHER_PROJECT_ID.toString()]);
2159
+ });
2160
+
2161
+ test("a list the update sends is checked once, not again as the row's scope", async () => {
2162
+ store(INCIDENT_CALL_CRITICAL);
2163
+
2164
+ await update({ incidentSeverities: [MAJOR_INCIDENT] });
2165
+
2166
+ expect(incidentSeverityCountBy).toHaveBeenCalledTimes(1);
2167
+ });
2168
+
2169
+ test.each([
2170
+ ["no team", { omitTeam: true }],
2171
+ ["no project", { omitProject: true }],
2172
+ ])(
2173
+ "a stored row with %s is skipped",
2174
+ async (_label: string, broken: Partial<StoredRuleInput>) => {
2175
+ store({ ...INCIDENT_CALL_CRITICAL, ...broken });
2176
+
2177
+ await expect(
2178
+ onBeforeUpdate(
2179
+ updateByFor(
2180
+ { notificationChannel: ComplianceNotificationChannel.Push },
2181
+ { props: { isRoot: true } },
2182
+ ),
2183
+ ),
2184
+ ).resolves.toBeDefined();
2185
+
2186
+ expect(siblingReads()).toEqual([]);
2187
+ },
2188
+ );
2189
+
2190
+ test("every row an update matches is checked against its own team", async () => {
2191
+ store(INCIDENT_CALL_CRITICAL, {
2192
+ ...INCIDENT_CALL_CRITICAL,
2193
+ id: SIBLING_SETTING_ID,
2194
+ teamId: OTHER_TEAM_ID,
2195
+ });
2196
+
2197
+ await onBeforeUpdate(
2198
+ updateByFor(
2199
+ { notificationChannel: ComplianceNotificationChannel.Push },
2200
+ {
2201
+ query: {
2202
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2203
+ },
2204
+ },
2205
+ ),
2206
+ );
2207
+
2208
+ expect(
2209
+ siblingReads().map((read: JSONObject): string => {
2210
+ return String((read["query"] as JSONObject)["teamId"]);
2211
+ }),
2212
+ ).toEqual([TEAM_ID.toString(), OTHER_TEAM_ID.toString()]);
2213
+ });
2214
+ });
2215
+
2216
+ describe("TeamComplianceSettingService onBeforeUpdate - a rule type change", () => {
2217
+ test("to a method rule clears the channel and every severity, including the ones already stored", async () => {
2218
+ store(INCIDENT_CALL_CRITICAL);
2219
+
2220
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
2221
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
2222
+ });
2223
+
2224
+ await onBeforeUpdate(updateBy);
2225
+
2226
+ expect(dataOf(updateBy)).toEqual({
2227
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
2228
+ notificationChannel: null,
2229
+ incidentSeverities: [],
2230
+ alertSeverities: [],
2231
+ });
2232
+ // Nothing left to validate; the duplicate lookup is for the new type.
2233
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
2234
+ expect(
2235
+ (siblingReads()[0]?.["query"] as JSONObject | undefined)?.["ruleType"],
2236
+ ).toBe(ComplianceRuleType.HasNotificationEmailMethod);
2237
+ });
2238
+
2239
+ test("to a method rule collides with an existing rule of that type", async () => {
2240
+ store(INCIDENT_CALL_CRITICAL, {
2241
+ id: SIBLING_SETTING_ID,
2242
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
2243
+ });
2244
+
2245
+ await expect(
2246
+ update({ ruleType: ComplianceRuleType.HasNotificationEmailMethod }),
2247
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
2248
+ });
2249
+
2250
+ test("from incident to alert drops the incident severities and keeps the channel", async () => {
2251
+ store(INCIDENT_CALL_CRITICAL);
2252
+
2253
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
2254
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
2255
+ });
2256
+
2257
+ await onBeforeUpdate(updateBy);
2258
+
2259
+ expect(dataOf(updateBy)).toEqual({
2260
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
2261
+ incidentSeverities: [],
2262
+ });
2263
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
2264
+ expect(alertSeverityCountBy).not.toHaveBeenCalled();
2265
+ });
2266
+
2267
+ test("from incident to alert is judged as 'Call for every alert severity'", async () => {
2268
+ store(INCIDENT_CALL_CRITICAL, {
2269
+ id: SIBLING_SETTING_ID,
2270
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
2271
+ notificationChannel: ComplianceNotificationChannel.Call,
2272
+ });
2273
+
2274
+ await expect(
2275
+ update({ ruleType: ComplianceRuleType.HasAlertOnCallRules }),
2276
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
2277
+ });
2278
+
2279
+ test("from incident to alert with new alert severities validates those", async () => {
2280
+ store(INCIDENT_CALL_CRITICAL);
2281
+
2282
+ await expect(
2283
+ update({
2284
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
2285
+ alertSeverities: [FOREIGN_ALERT],
2286
+ }),
2287
+ ).rejects.toThrow(FOREIGN_ALERT_SEVERITY_MESSAGE);
2288
+
2289
+ await expect(
2290
+ update({
2291
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
2292
+ alertSeverities: [CRITICAL_ALERT],
2293
+ }),
2294
+ ).resolves.toBeDefined();
2295
+ });
2296
+
2297
+ test("to the incident episode rule keeps the stored incident scope and re-validates it", async () => {
2298
+ store(INCIDENT_CALL_CRITICAL);
2299
+
2300
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
2301
+ ruleType: ComplianceRuleType.HasIncidentEpisodeOnCallRules,
2302
+ });
2303
+
2304
+ await onBeforeUpdate(updateBy);
2305
+
2306
+ expect(dataOf(updateBy)).toEqual({
2307
+ ruleType: ComplianceRuleType.HasIncidentEpisodeOnCallRules,
2308
+ alertSeverities: [],
2309
+ });
2310
+ expect(
2311
+ includedIds(
2312
+ countCalls(incidentSeverityCountBy)[0]!["query"] as JSONObject,
2313
+ ),
2314
+ ).toEqual([CRITICAL_INCIDENT]);
2315
+ expect(
2316
+ (siblingReads()[0]?.["query"] as JSONObject | undefined)?.["ruleType"],
2317
+ ).toBe(ComplianceRuleType.HasIncidentEpisodeOnCallRules);
2318
+ });
2319
+
2320
+ test("an unknown stored type can be repaired by setting a real one", async () => {
2321
+ store({ id: SETTING_ID, ruleType: "RequireTwoFactorAuth" });
2322
+
2323
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
2324
+ ruleType: ComplianceRuleType.HasNotificationPushMethod,
2325
+ });
2326
+
2327
+ await expect(onBeforeUpdate(updateBy)).resolves.toBeDefined();
2328
+ expect(siblingReads()).toHaveLength(1);
2329
+ });
2330
+ });
2331
+
2332
+ describe("TeamComplianceSettingService onBeforeUpdate - options the stored type does not use", () => {
2333
+ test("alert severities sent for an incident rule are dropped, not stored unvalidated", async () => {
2334
+ store(INCIDENT_CALL_CRITICAL);
2335
+
2336
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
2337
+ alertSeverities: [{ _id: FOREIGN_ALERT }],
2338
+ });
2339
+
2340
+ await expect(onBeforeUpdate(updateBy)).resolves.toBeDefined();
2341
+
2342
+ expect(dataOf(updateBy)["alertSeverities"]).toEqual([]);
2343
+ expect(alertSeverityCountBy).not.toHaveBeenCalled();
2344
+ });
2345
+
2346
+ test("a channel sent for a method rule is dropped", async () => {
2347
+ store({
2348
+ id: SETTING_ID,
2349
+ ruleType: ComplianceRuleType.HasNotificationSMSMethod,
2350
+ });
2351
+
2352
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
2353
+ notificationChannel: ComplianceNotificationChannel.Call,
2354
+ incidentSeverities: [CRITICAL_INCIDENT],
2355
+ });
2356
+
2357
+ await onBeforeUpdate(updateBy);
2358
+
2359
+ expect(dataOf(updateBy)).toEqual({
2360
+ notificationChannel: null,
2361
+ incidentSeverities: [],
2362
+ });
2363
+ });
2364
+
2365
+ test("options the type does use are kept, the severities as the ids they name", async () => {
2366
+ store(INCIDENT_CALL_CRITICAL);
2367
+
2368
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
2369
+ notificationChannel: ComplianceNotificationChannel.Push,
2370
+ incidentSeverities: [{ _id: MAJOR_INCIDENT }],
2371
+ });
2372
+
2373
+ await onBeforeUpdate(updateBy);
2374
+
2375
+ expect(Object.keys(dataOf(updateBy)).sort()).toEqual([
2376
+ "incidentSeverities",
2377
+ "notificationChannel",
2378
+ ]);
2379
+ expect(dataOf(updateBy)["notificationChannel"]).toBe(
2380
+ ComplianceNotificationChannel.Push,
2381
+ );
2382
+ expect(
2383
+ writtenIds(dataOf(updateBy)["incidentSeverities"], IncidentSeverity),
2384
+ ).toEqual([MAJOR_INCIDENT]);
2385
+ });
2386
+ });
2387
+
2388
+ /*
2389
+ * ---------------------------------------------------------------------------
2390
+ * The severities that are checked are the severities that are written.
2391
+ * ---------------------------------------------------------------------------
2392
+ *
2393
+ * The relation save writes whatever DatabaseService.sanitizeCreateOrUpdate
2394
+ * reads out of each list item, and inserts one join row per item. The hooks
2395
+ * used to check their OWN reading of the list and hand the list on as sent,
2396
+ * so the two could disagree: an item the save reads by its `id` was checked
2397
+ * by its `_id` or not at all - which let another project's severity through -
2398
+ * and a repeat the check counted once was inserted twice, which the join
2399
+ * table's primary key refuses.
2400
+ */
2401
+
2402
+ describe("TeamComplianceSettingService - the severities checked are the severities written", () => {
2403
+ test("create: repeats and case collapse to one lower-case id each, handed to the save as models", async () => {
2404
+ const result: OnCreate<TeamComplianceSetting> = await create({
2405
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2406
+ notificationChannel: ComplianceNotificationChannel.Call,
2407
+ incidentSeverities: [
2408
+ CRITICAL_INCIDENT.toUpperCase(),
2409
+ { _id: CRITICAL_INCIDENT },
2410
+ new ObjectID(MAJOR_INCIDENT),
2411
+ CRITICAL_INCIDENT,
2412
+ ],
2413
+ });
2414
+
2415
+ expect(
2416
+ writtenIds(result.createBy.data.incidentSeverities, IncidentSeverity),
2417
+ ).toEqual([CRITICAL_INCIDENT, MAJOR_INCIDENT]);
2418
+ expect(
2419
+ includedIds(
2420
+ countCalls(incidentSeverityCountBy)[0]!["query"] as JSONObject,
2421
+ ),
2422
+ ).toEqual([CRITICAL_INCIDENT, MAJOR_INCIDENT]);
2423
+ });
2424
+
2425
+ test("create: an alert list is written as alert severities", async () => {
2426
+ const result: OnCreate<TeamComplianceSetting> = await create({
2427
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
2428
+ alertSeverities: [{ id: MAJOR_ALERT }, MAJOR_ALERT.toUpperCase()],
2429
+ });
2430
+
2431
+ expect(
2432
+ writtenIds(result.createBy.data.alertSeverities, AlertSeverity),
2433
+ ).toEqual([MAJOR_ALERT]);
2434
+ });
2435
+
2436
+ test("update: the list sent is normalised once, and each row is checked against exactly that", async () => {
2437
+ store(INCIDENT_CALL_CRITICAL);
2438
+
2439
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor({
2440
+ incidentSeverities: [
2441
+ { _id: MAJOR_INCIDENT },
2442
+ { _id: MAJOR_INCIDENT.toUpperCase() },
2443
+ ],
2444
+ });
2445
+
2446
+ await onBeforeUpdate(updateBy);
2447
+
2448
+ expect(
2449
+ writtenIds(dataOf(updateBy)["incidentSeverities"], IncidentSeverity),
2450
+ ).toEqual([MAJOR_INCIDENT]);
2451
+ expect(
2452
+ includedIds(
2453
+ countCalls(incidentSeverityCountBy)[0]!["query"] as JSONObject,
2454
+ ),
2455
+ ).toEqual([MAJOR_INCIDENT]);
2456
+ });
2457
+
2458
+ test("update: a multi-row update over rows of both kinds normalises both lists it sends", async () => {
2459
+ store(INCIDENT_CALL_CRITICAL, {
2460
+ id: SIBLING_SETTING_ID,
2461
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
2462
+ notificationChannel: ComplianceNotificationChannel.Push,
2463
+ alertSeverityIds: [CRITICAL_ALERT],
2464
+ });
2465
+
2466
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor(
2467
+ {
2468
+ incidentSeverities: [MAJOR_INCIDENT, MAJOR_INCIDENT],
2469
+ alertSeverities: [MAJOR_ALERT.toUpperCase(), { _id: MAJOR_ALERT }],
2470
+ },
2471
+ { query: { teamId: TEAM_ID } },
2472
+ );
2473
+
2474
+ await onBeforeUpdate(updateBy);
2475
+
2476
+ expect(
2477
+ writtenIds(dataOf(updateBy)["incidentSeverities"], IncidentSeverity),
2478
+ ).toEqual([MAJOR_INCIDENT]);
2479
+ expect(
2480
+ writtenIds(dataOf(updateBy)["alertSeverities"], AlertSeverity),
2481
+ ).toEqual([MAJOR_ALERT]);
2482
+ });
2483
+
2484
+ const READ_BY_ID: Array<[string, JSONObject]> = [
2485
+ ["a number _id", { _id: 1, id: FOREIGN_INCIDENT }],
2486
+ ["a boolean _id", { _id: true, id: FOREIGN_INCIDENT }],
2487
+ [
2488
+ "an ObjectID _id naming this project's own severity",
2489
+ {
2490
+ _id: new ObjectID(CRITICAL_INCIDENT) as unknown as JSONObject,
2491
+ id: FOREIGN_INCIDENT,
2492
+ },
2493
+ ],
2494
+ ["an object _id", { _id: {}, id: FOREIGN_INCIDENT }],
2495
+ ];
2496
+
2497
+ test.each(READ_BY_ID)(
2498
+ "update: an item with %s is read by its id, as the save reads it - so another project's severity is refused",
2499
+ async (_label: string, item: JSONObject) => {
2500
+ store(INCIDENT_CALL_CRITICAL);
2501
+
2502
+ await expect(update({ incidentSeverities: [item] })).rejects.toThrow(
2503
+ FOREIGN_INCIDENT_SEVERITY_MESSAGE,
2504
+ );
2505
+
2506
+ expect(
2507
+ includedIds(
2508
+ countCalls(incidentSeverityCountBy)[0]!["query"] as JSONObject,
2509
+ ),
2510
+ ).toEqual([FOREIGN_INCIDENT]);
2511
+ },
2512
+ );
2513
+
2514
+ test.each(READ_BY_ID)(
2515
+ "create: an item with %s is read by its id too",
2516
+ async (_label: string, item: JSONObject) => {
2517
+ await expect(
2518
+ create({
2519
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2520
+ incidentSeverities: [item],
2521
+ }),
2522
+ ).rejects.toThrow(FOREIGN_INCIDENT_SEVERITY_MESSAGE);
2523
+ },
2524
+ );
2525
+
2526
+ test("the same trick cannot dodge the duplicate check: a rule is compared by the id that would be written", async () => {
2527
+ store({ ...INCIDENT_CALL_CRITICAL, id: SIBLING_SETTING_ID });
2528
+
2529
+ await expect(
2530
+ create({
2531
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2532
+ notificationChannel: ComplianceNotificationChannel.Call,
2533
+ incidentSeverities: [{ _id: 1, id: CRITICAL_INCIDENT }],
2534
+ }),
2535
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
2536
+ });
2537
+
2538
+ const DROPPED_BY_THE_SAVE: Array<[string, unknown]> = [
2539
+ [
2540
+ "an ObjectID _id and no id (the save drops it: the rule would become 'every severity')",
2541
+ { _id: new ObjectID(CRITICAL_INCIDENT) },
2542
+ ],
2543
+ ["a boolean _id and no id", { _id: true }],
2544
+ ["an empty object", {}],
2545
+ ["a name and no id", { name: "Critical" }],
2546
+ ["a number", 7],
2547
+ ["null", null],
2548
+ ["a nested list", [CRITICAL_INCIDENT]],
2549
+ ["a model with no id", new IncidentSeverity()],
2550
+ ];
2551
+
2552
+ test.each(DROPPED_BY_THE_SAVE)(
2553
+ "create: an item that names no severity - %s - is refused before anything is read",
2554
+ async (_label: string, item: unknown) => {
2555
+ await expect(
2556
+ create({
2557
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2558
+ incidentSeverities: [CRITICAL_INCIDENT, item],
2559
+ }),
2560
+ ).rejects.toThrow(
2561
+ new BadDataException(
2562
+ "Every incident severity of a compliance rule must be a severity id.",
2563
+ ),
2564
+ );
2565
+
2566
+ expectNoReads();
2567
+ },
2568
+ );
2569
+
2570
+ test.each(DROPPED_BY_THE_SAVE)(
2571
+ "update: an item that names no severity - %s - is refused, and nothing is checked or written",
2572
+ async (_label: string, item: unknown) => {
2573
+ store(INCIDENT_CALL_CRITICAL);
2574
+
2575
+ await expect(
2576
+ update({ incidentSeverities: [MAJOR_INCIDENT, item] }),
2577
+ ).rejects.toThrow(
2578
+ "Every incident severity of a compliance rule must be a severity id.",
2579
+ );
2580
+
2581
+ expect(incidentSeverityCountBy).not.toHaveBeenCalled();
2582
+ expect(siblingReads()).toEqual([]);
2583
+ },
2584
+ );
2585
+
2586
+ test("the refusal names the list's own kind", async () => {
2587
+ await expect(
2588
+ create({
2589
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
2590
+ alertSeverities: [{ _id: true }],
2591
+ }),
2592
+ ).rejects.toThrow(
2593
+ "Every alert severity of a compliance rule must be a severity id.",
2594
+ );
2595
+ });
2596
+
2597
+ test.each([
2598
+ ["an id string", CRITICAL_INCIDENT],
2599
+ ["a single {_id}", { _id: CRITICAL_INCIDENT }],
2600
+ ["a number", 3],
2601
+ ])(
2602
+ "a severity 'list' that is %s is refused - the save would ignore it, the check read it as every severity",
2603
+ async (_label: string, value: unknown) => {
2604
+ await expect(
2605
+ create({
2606
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2607
+ incidentSeverities: value,
2608
+ }),
2609
+ ).rejects.toThrow(
2610
+ "The incident severities of a compliance rule must be a list of severity ids.",
2611
+ );
2612
+
2613
+ store(INCIDENT_CALL_CRITICAL);
2614
+
2615
+ await expect(update({ incidentSeverities: value })).rejects.toThrow(
2616
+ "The incident severities of a compliance rule must be a list of severity ids.",
2617
+ );
2618
+ },
2619
+ );
2620
+
2621
+ test("null is an empty list, and is written as one", async () => {
2622
+ const result: OnCreate<TeamComplianceSetting> = await create({
2623
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2624
+ incidentSeverities: null,
2625
+ });
2626
+
2627
+ expect(result.createBy.data.incidentSeverities).toEqual([]);
2628
+ });
2629
+
2630
+ test("a list that is not sent stays unsent", async () => {
2631
+ const result: OnCreate<TeamComplianceSetting> = await create({
2632
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2633
+ });
2634
+
2635
+ expect(modelValue(result.createBy.data, "incidentSeverities")).toBe(
2636
+ undefined,
2637
+ );
2638
+ expect(modelValue(result.createBy.data, "alertSeverities")).toBe(undefined);
2639
+ });
2640
+ });
2641
+
2642
+ type SanitizeFunction = (
2643
+ data: JSONObject,
2644
+ props: DatabaseCommonInteractionProps,
2645
+ isUpdate: boolean,
2646
+ ) => Promise<JSONObject>;
2647
+
2648
+ /*
2649
+ * The valid severity ids DatabaseService.sanitizeCreateOrUpdate - what the
2650
+ * relation save writes from - reads out of an incident severity list,
2651
+ * trimmed and lower-cased.
2652
+ */
2653
+ const writtenBySave: (list: Array<unknown>) => Promise<Array<string>> = async (
2654
+ list: Array<unknown>,
2655
+ ): Promise<Array<string>> => {
2656
+ const service: TeamComplianceSettingServiceClass =
2657
+ new TeamComplianceSettingServiceClass();
2658
+
2659
+ const sanitized: JSONObject = await (
2660
+ service as unknown as { sanitizeCreateOrUpdate: SanitizeFunction }
2661
+ ).sanitizeCreateOrUpdate(
2662
+ { incidentSeverities: list } as JSONObject,
2663
+ { isRoot: true },
2664
+ true,
2665
+ );
2666
+
2667
+ const written: unknown = sanitized["incidentSeverities"];
2668
+
2669
+ return (Array.isArray(written) ? written : [])
2670
+ .map((item: unknown): string => {
2671
+ return String((item as { _id?: unknown })._id)
2672
+ .trim()
2673
+ .toLowerCase();
2674
+ })
2675
+ .filter((id: string): boolean => {
2676
+ return ObjectID.isValidUUID(id);
2677
+ });
2678
+ };
2679
+
2680
+ const resolveOrNull: (item: unknown) => Array<string> | null = (
2681
+ item: unknown,
2682
+ ): Array<string> | null => {
2683
+ try {
2684
+ return TeamComplianceSettingServiceClass.resolveSentSeverityIds(
2685
+ [item],
2686
+ ComplianceSeverityKind.Incident,
2687
+ );
2688
+ } catch {
2689
+ return null;
2690
+ }
2691
+ };
2692
+
2693
+ const MAJOR_INCIDENT_MODEL: IncidentSeverity = new IncidentSeverity();
2694
+ MAJOR_INCIDENT_MODEL._id = MAJOR_INCIDENT;
2695
+
2696
+ describe("TeamComplianceSettingService.resolveSentSeverityIds", () => {
2697
+ test("reads an item exactly as the relation save does: id string or ObjectID, else a string _id, else a string id", () => {
2698
+ const model: IncidentSeverity = new IncidentSeverity();
2699
+ model._id = MINOR_INCIDENT;
2700
+
2701
+ expect(
2702
+ TeamComplianceSettingServiceClass.resolveSentSeverityIds(
2703
+ [
2704
+ CRITICAL_INCIDENT,
2705
+ new ObjectID(MAJOR_INCIDENT),
2706
+ model,
2707
+ { _id: CRITICAL_ALERT, id: FOREIGN_INCIDENT },
2708
+ { _id: 1, id: MAJOR_ALERT },
2709
+ { _id: new ObjectID(FOREIGN_ALERT), id: FOREIGN_INCIDENT },
2710
+ ],
2711
+ ComplianceSeverityKind.Incident,
2712
+ ),
2713
+ ).toEqual([
2714
+ CRITICAL_INCIDENT,
2715
+ MAJOR_INCIDENT,
2716
+ MINOR_INCIDENT,
2717
+ CRITICAL_ALERT,
2718
+ MAJOR_ALERT,
2719
+ FOREIGN_INCIDENT,
2720
+ ]);
2721
+ });
2722
+
2723
+ test("trims, lower-cases and de-duplicates, keeping the first occurrence", () => {
2724
+ expect(
2725
+ TeamComplianceSettingServiceClass.resolveSentSeverityIds(
2726
+ [
2727
+ MINOR_INCIDENT,
2728
+ ` ${CRITICAL_INCIDENT.toUpperCase()} `,
2729
+ CRITICAL_INCIDENT,
2730
+ { _id: MINOR_INCIDENT },
2731
+ MAJOR_INCIDENT,
2732
+ ],
2733
+ ComplianceSeverityKind.Incident,
2734
+ ),
2735
+ ).toEqual([MINOR_INCIDENT, CRITICAL_INCIDENT, MAJOR_INCIDENT]);
2736
+ });
2737
+
2738
+ test("null and an empty list are empty", () => {
2739
+ for (const value of [null, []]) {
2740
+ expect(
2741
+ TeamComplianceSettingServiceClass.resolveSentSeverityIds(
2742
+ value,
2743
+ ComplianceSeverityKind.Alert,
2744
+ ),
2745
+ ).toEqual([]);
2746
+ }
2747
+ });
2748
+
2749
+ test("an id that is not a uuid is refused by name", () => {
2750
+ expect(() => {
2751
+ TeamComplianceSettingServiceClass.resolveSentSeverityIds(
2752
+ ["not-a-uuid"],
2753
+ ComplianceSeverityKind.Incident,
2754
+ );
2755
+ }).toThrow(/Invalid ID format: "not-a-uuid"/);
2756
+
2757
+ for (const blank of ["", " "]) {
2758
+ expect(() => {
2759
+ TeamComplianceSettingServiceClass.resolveSentSeverityIds(
2760
+ [blank],
2761
+ ComplianceSeverityKind.Incident,
2762
+ );
2763
+ }).toThrow(/Invalid ID format/);
2764
+ }
2765
+ });
2766
+
2767
+ /*
2768
+ * The property the hooks rely on, against the real sanitizer: whatever id
2769
+ * this reads from an item is the id the save would write for it, and an
2770
+ * item it refuses is one the save would write no severity for at all.
2771
+ */
2772
+ test.each<[string, unknown]>([
2773
+ ["an id string", CRITICAL_INCIDENT],
2774
+ ["an upper-case id string", MAJOR_INCIDENT.toUpperCase()],
2775
+ ["an ObjectID", new ObjectID(MINOR_INCIDENT)],
2776
+ ["{_id}", { _id: CRITICAL_INCIDENT }],
2777
+ ["{id}", { id: MAJOR_INCIDENT }],
2778
+ ["{_id, id}", { _id: CRITICAL_INCIDENT, id: FOREIGN_INCIDENT }],
2779
+ ["{_id: number, id}", { _id: 1, id: FOREIGN_INCIDENT }],
2780
+ ["{_id: true, id}", { _id: true, id: FOREIGN_INCIDENT }],
2781
+ [
2782
+ "{_id: ObjectID, id}",
2783
+ { _id: new ObjectID(CRITICAL_INCIDENT), id: FOREIGN_INCIDENT },
2784
+ ],
2785
+ ["{_id: {}, id}", { _id: {}, id: FOREIGN_INCIDENT }],
2786
+ ["{_id: ObjectID} alone", { _id: new ObjectID(CRITICAL_INCIDENT) }],
2787
+ ["{_id: true} alone", { _id: true }],
2788
+ ["{_id: ''} and an id", { _id: "", id: MINOR_INCIDENT }],
2789
+ ["{}", {}],
2790
+ ["{name}", { name: "Critical" }],
2791
+ ["a number", 7],
2792
+ ["null", null],
2793
+ ["a nested list", [CRITICAL_INCIDENT]],
2794
+ ["a model with an id", MAJOR_INCIDENT_MODEL],
2795
+ ["a model with no id", new IncidentSeverity()],
2796
+ ["not a uuid", "not-a-uuid"],
2797
+ ["an empty string", ""],
2798
+ ])(
2799
+ "%s: the id it reads is the id the save writes",
2800
+ async (_label: string, item: unknown) => {
2801
+ const resolved: Array<string> | null = resolveOrNull(item);
2802
+ const written: Array<string> = await writtenBySave([item]);
2803
+
2804
+ expect(resolved === null ? [] : resolved).toEqual(written);
2805
+ },
2806
+ );
2807
+
2808
+ test("a list the hooks have normalised is written by the save exactly as it was checked", async () => {
2809
+ const createBy: CreateBy<TeamComplianceSetting> = createByFor(
2810
+ newRule({
2811
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2812
+ incidentSeverities: [
2813
+ { _id: 1, id: MAJOR_INCIDENT },
2814
+ CRITICAL_INCIDENT.toUpperCase(),
2815
+ { id: CRITICAL_INCIDENT },
2816
+ new ObjectID(MAJOR_INCIDENT),
2817
+ ],
2818
+ }),
2819
+ );
2820
+
2821
+ await onBeforeCreate(createBy);
2822
+
2823
+ const checked: Array<string> = includedIds(
2824
+ countCalls(incidentSeverityCountBy)[0]!["query"] as JSONObject,
2825
+ );
2826
+
2827
+ expect(
2828
+ await writtenBySave(
2829
+ createBy.data.incidentSeverities as unknown as Array<unknown>,
2830
+ ),
2831
+ ).toEqual([MAJOR_INCIDENT, CRITICAL_INCIDENT]);
2832
+ expect([...checked].sort()).toEqual(
2833
+ [MAJOR_INCIDENT, CRITICAL_INCIDENT].sort(),
2834
+ );
2835
+ });
2836
+ });
2837
+
2838
+ /*
2839
+ * ---------------------------------------------------------------------------
2840
+ * Who may ask.
2841
+ * ---------------------------------------------------------------------------
2842
+ *
2843
+ * DatabaseService checks a caller's create and update permission only AFTER
2844
+ * these hooks, and the hooks read as root in whichever project the request
2845
+ * names. Their refusals - "that severity is not in this project", "that team
2846
+ * already has this rule" - described another project's severities and rules
2847
+ * to anyone signed in who named it. The hooks now make the same permission
2848
+ * check first.
2849
+ */
2850
+
2851
+ const EDITORS_OF_EMPTIED_RULES: Array<
2852
+ [string, DatabaseCommonInteractionProps]
2853
+ > = [
2854
+ ["a team editor", EDITOR_PROPS],
2855
+ ["a project admin", memberProps([Permission.ProjectAdmin])],
2856
+ ];
2857
+
2858
+ describe("TeamComplianceSettingService - the caller's permission is checked before anything is read", () => {
2859
+ const OUTSIDERS: Array<[string, DatabaseCommonInteractionProps]> = [
2860
+ ["a signed-in user with no permission in the project", OUTSIDER_PROPS],
2861
+ [
2862
+ "a member who may only read the project's teams",
2863
+ memberProps([Permission.ProjectMember, Permission.ReadProjectTeam]),
2864
+ ],
2865
+ [
2866
+ "a team editor of another project who names this one",
2867
+ {
2868
+ ...memberProps(
2869
+ [Permission.ReadProjectTeam, Permission.EditProjectTeam],
2870
+ OTHER_PROJECT_ID,
2871
+ ),
2872
+ tenantId: PROJECT_ID,
2873
+ },
2874
+ ],
2875
+ ];
2876
+
2877
+ test.each(OUTSIDERS)(
2878
+ "create: %s is refused before any severity or rule is read, so the refusal says nothing about the project",
2879
+ async (_label: string, props: DatabaseCommonInteractionProps) => {
2880
+ // A rule the probe below would duplicate.
2881
+ store({ ...INCIDENT_CALL_CRITICAL, id: SIBLING_SETTING_ID });
2882
+
2883
+ await expect(
2884
+ onBeforeCreate(
2885
+ createByFor(
2886
+ newRule({
2887
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2888
+ notificationChannel: ComplianceNotificationChannel.Call,
2889
+ incidentSeverities: [FOREIGN_INCIDENT],
2890
+ }),
2891
+ props,
2892
+ ),
2893
+ ),
2894
+ ).rejects.toThrow(NotAuthorizedException);
2895
+
2896
+ await expect(
2897
+ onBeforeCreate(
2898
+ createByFor(
2899
+ newRule({
2900
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
2901
+ notificationChannel: ComplianceNotificationChannel.Call,
2902
+ incidentSeverities: [CRITICAL_INCIDENT],
2903
+ }),
2904
+ props,
2905
+ ),
2906
+ ),
2907
+ ).rejects.toThrow(NotAuthorizedException);
2908
+
2909
+ expectNoReads();
2910
+ },
2911
+ );
2912
+
2913
+ test.each(OUTSIDERS)(
2914
+ "update: %s changing a rule's scope is refused before anything is read",
2915
+ async (_label: string, props: DatabaseCommonInteractionProps) => {
2916
+ store(INCIDENT_CALL_CRITICAL, {
2917
+ ...INCIDENT_CALL_CRITICAL,
2918
+ id: SIBLING_SETTING_ID,
2919
+ notificationChannel: ComplianceNotificationChannel.Push,
2920
+ });
2921
+
2922
+ for (const data of [
2923
+ { incidentSeverities: [FOREIGN_INCIDENT] },
2924
+ { notificationChannel: ComplianceNotificationChannel.Push },
2925
+ ]) {
2926
+ await expect(
2927
+ onBeforeUpdate(updateByFor({ ...data }, { props: props })),
2928
+ ).rejects.toThrow(NotAuthorizedException);
2929
+ }
2930
+
2931
+ expectNoReads();
2932
+ },
2933
+ );
2934
+
2935
+ test.each(OUTSIDERS)(
2936
+ "update: %s switching a rule off gets nothing from this hook - it reads nothing, and _updateBy's own check refuses the write",
2937
+ async (_label: string, props: DatabaseCommonInteractionProps) => {
2938
+ store(INCIDENT_CALL_CRITICAL);
2939
+
2940
+ await expect(
2941
+ onBeforeUpdate(updateByFor({ enabled: false }, { props: props })),
2942
+ ).resolves.toBeDefined();
2943
+
2944
+ expectNoReads();
2945
+ },
2946
+ );
2947
+
2948
+ test.each(OUTSIDERS)(
2949
+ "update: %s switching a rule on is refused before anything is read, so the refusal cannot tell an emptied rule from any other",
2950
+ async (_label: string, props: DatabaseCommonInteractionProps) => {
2951
+ store({ ...EMPTIED_BY_DELETE });
2952
+
2953
+ await expect(
2954
+ onBeforeUpdate(updateByFor({ enabled: true }, { props: props })),
2955
+ ).rejects.toThrow(NotAuthorizedException);
2956
+
2957
+ expectNoReads();
2958
+ },
2959
+ );
2960
+
2961
+ test.each(EDITORS_OF_EMPTIED_RULES)(
2962
+ "update: %s re-scoping an emptied rule writes options the update check _updateBy repeats allows",
2963
+ async (_label: string, props: DatabaseCommonInteractionProps) => {
2964
+ store({ ...EMPTIED_BY_DELETE });
2965
+
2966
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor(
2967
+ { incidentSeverities: [MAJOR_INCIDENT], enabled: true },
2968
+ { props: props },
2969
+ );
2970
+
2971
+ await onBeforeUpdate(updateBy);
2972
+
2973
+ expect("options" in dataOf(updateBy)).toBe(true);
2974
+
2975
+ await expect(
2976
+ ModelPermission.checkUpdateQueryPermissions(
2977
+ TeamComplianceSetting,
2978
+ { _id: SETTING_ID },
2979
+ updateBy.data,
2980
+ props,
2981
+ ),
2982
+ ).resolves.toBeDefined();
2983
+ },
2984
+ );
2985
+
2986
+ const EDITORS: Array<[string, DatabaseCommonInteractionProps]> = [
2987
+ ["a team editor", EDITOR_PROPS],
2988
+ ["a project admin", memberProps([Permission.ProjectAdmin])],
2989
+ ["a project owner", memberProps([Permission.ProjectOwner])],
2990
+ ];
2991
+
2992
+ test.each(EDITORS)(
2993
+ "create: %s passes, and what the hook leaves - cleared options included - passes the create check DatabaseService repeats",
2994
+ async (_label: string, props: DatabaseCommonInteractionProps) => {
2995
+ for (const input of [
2996
+ {
2997
+ ruleType: ComplianceRuleType.HasNotificationCallMethod,
2998
+ notificationChannel: ComplianceNotificationChannel.SMS,
2999
+ incidentSeverities: [FOREIGN_INCIDENT],
3000
+ alertSeverities: [FOREIGN_ALERT],
3001
+ },
3002
+ {
3003
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3004
+ notificationChannel: ComplianceNotificationChannel.Call,
3005
+ incidentSeverities: [CRITICAL_INCIDENT, CRITICAL_INCIDENT],
3006
+ alertSeverities: [FOREIGN_ALERT],
3007
+ },
3008
+ ]) {
3009
+ const result: OnCreate<TeamComplianceSetting> = await onBeforeCreate(
3010
+ createByFor(newRule(input), props),
3011
+ );
3012
+
3013
+ expect((): void => {
3014
+ ModelPermission.checkCreatePermissions(
3015
+ TeamComplianceSetting,
3016
+ result.createBy.data,
3017
+ props,
3018
+ );
3019
+ }).not.toThrow();
3020
+ }
3021
+ },
3022
+ );
3023
+
3024
+ test("create: Edit Teams on its own is enough to create a rule", async () => {
3025
+ const props: DatabaseCommonInteractionProps = memberProps([
3026
+ Permission.EditProjectTeam,
3027
+ ]);
3028
+
3029
+ const result: OnCreate<TeamComplianceSetting> = await onBeforeCreate(
3030
+ createByFor(
3031
+ newRule({
3032
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
3033
+ notificationChannel: ComplianceNotificationChannel.Push,
3034
+ alertSeverities: [CRITICAL_ALERT],
3035
+ incidentSeverities: [CRITICAL_INCIDENT],
3036
+ }),
3037
+ props,
3038
+ ),
3039
+ );
3040
+
3041
+ expect((): void => {
3042
+ ModelPermission.checkCreatePermissions(
3043
+ TeamComplianceSetting,
3044
+ result.createBy.data,
3045
+ props,
3046
+ );
3047
+ }).not.toThrow();
3048
+ });
3049
+
3050
+ test.each(EDITORS)(
3051
+ "update: %s passes, and what the hook leaves passes the update check _updateBy repeats",
3052
+ async (_label: string, props: DatabaseCommonInteractionProps) => {
3053
+ store(INCIDENT_CALL_CRITICAL);
3054
+
3055
+ for (const data of [
3056
+ { ruleType: ComplianceRuleType.HasNotificationEmailMethod },
3057
+ {
3058
+ incidentSeverities: [{ _id: MAJOR_INCIDENT }, MAJOR_INCIDENT],
3059
+ alertSeverities: [FOREIGN_ALERT],
3060
+ },
3061
+ { notificationChannel: null },
3062
+ ]) {
3063
+ const updateBy: UpdateBy<TeamComplianceSetting> = updateByFor(
3064
+ { ...data },
3065
+ { props: props },
3066
+ );
3067
+
3068
+ await onBeforeUpdate(updateBy);
3069
+
3070
+ await expect(
3071
+ ModelPermission.checkUpdateQueryPermissions(
3072
+ TeamComplianceSetting,
3073
+ { _id: SETTING_ID },
3074
+ updateBy.data,
3075
+ props,
3076
+ ),
3077
+ ).resolves.toBeDefined();
3078
+ }
3079
+ },
3080
+ );
3081
+
3082
+ test("root and master-admin callers are not put through it", async () => {
3083
+ const createCheck: jest.SpyInstance = jest.spyOn(
3084
+ ModelPermission,
3085
+ "checkCreatePermissions",
3086
+ );
3087
+ const updateCheck: jest.SpyInstance = jest.spyOn(
3088
+ ModelPermission,
3089
+ "checkUpdateQueryPermissions",
3090
+ );
3091
+
3092
+ store(INCIDENT_CALL_CRITICAL);
3093
+
3094
+ await onBeforeCreate(
3095
+ createByFor(
3096
+ newRule({ ruleType: ComplianceRuleType.HasNotificationEmailMethod }),
3097
+ { isRoot: true },
3098
+ ),
3099
+ );
3100
+ await onBeforeCreate(
3101
+ createByFor(
3102
+ newRule({ ruleType: ComplianceRuleType.HasNotificationPushMethod }),
3103
+ { isMasterAdmin: true, userId: USER_ID, tenantId: PROJECT_ID },
3104
+ ),
3105
+ );
3106
+ await onBeforeUpdate(
3107
+ updateByFor(
3108
+ { notificationChannel: ComplianceNotificationChannel.Push },
3109
+ { props: { isRoot: true } },
3110
+ ),
3111
+ );
3112
+
3113
+ expect(createCheck).not.toHaveBeenCalled();
3114
+ expect(updateCheck).not.toHaveBeenCalled();
3115
+ });
3116
+ });
3117
+
3118
+ /*
3119
+ * ---------------------------------------------------------------------------
3120
+ * Severity deletes.
3121
+ * ---------------------------------------------------------------------------
3122
+ *
3123
+ * A rule's severity scope lives only in join rows, which cascade away with
3124
+ * the severity, and a rule with no severity left reads as a rule for every
3125
+ * severity of its kind. IncidentSeverityService and AlertSeverityService ask
3126
+ * which rules reference what they are about to delete, and once the delete
3127
+ * has happened pause and mark those left with no severity.
3128
+ */
3129
+
3130
+ const RULE_CRITICAL_ONLY: string = "77777777-7777-4777-8777-000000000001";
3131
+ const RULE_EPISODE_CRITICAL_ONLY: string =
3132
+ "77777777-7777-4777-8777-000000000002";
3133
+ const RULE_CRITICAL_AND_MAJOR: string = "77777777-7777-4777-8777-000000000003";
3134
+ const RULE_EVERY_SEVERITY: string = "77777777-7777-4777-8777-000000000004";
3135
+ const RULE_PAUSED: string = "77777777-7777-4777-8777-000000000005";
3136
+ const RULE_ALERT: string = "77777777-7777-4777-8777-000000000006";
3137
+ const RULE_METHOD: string = "77777777-7777-4777-8777-000000000007";
3138
+ const RULE_OTHER_PROJECT: string = "77777777-7777-4777-8777-000000000008";
3139
+ const RULE_MAJOR_ONLY: string = "77777777-7777-4777-8777-000000000009";
3140
+
3141
+ const deleting: (
3142
+ ids: Array<string>,
3143
+ project?: ObjectID,
3144
+ ) => Array<DeletedSeverity> = (
3145
+ ids: Array<string>,
3146
+ project?: ObjectID,
3147
+ ): Array<DeletedSeverity> => {
3148
+ return ids.map((id: string): DeletedSeverity => {
3149
+ return { _id: id, projectId: project || PROJECT_ID };
3150
+ });
3151
+ };
3152
+
3153
+ const MARKED: JSONObject = { [SEVERITIES_DELETED_OPTION]: true };
3154
+
3155
+ describe("TeamComplianceSettingService - a severity delete pauses and marks the rules it leaves with no severity", () => {
3156
+ let settingsUpdateBy: jest.SpyInstance;
3157
+
3158
+ beforeEach(() => {
3159
+ settingsUpdateBy = jest
3160
+ .spyOn(TeamComplianceSettingService, "updateBy")
3161
+ .mockResolvedValue(1 as never);
3162
+
3163
+ store(
3164
+ {
3165
+ id: RULE_CRITICAL_ONLY,
3166
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3167
+ notificationChannel: ComplianceNotificationChannel.Call,
3168
+ incidentSeverityIds: [CRITICAL_INCIDENT],
3169
+ },
3170
+ {
3171
+ id: RULE_EPISODE_CRITICAL_ONLY,
3172
+ ruleType: ComplianceRuleType.HasIncidentEpisodeOnCallRules,
3173
+ notificationChannel: ComplianceNotificationChannel.SMS,
3174
+ incidentSeverityIds: [CRITICAL_INCIDENT],
3175
+ },
3176
+ {
3177
+ id: RULE_CRITICAL_AND_MAJOR,
3178
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3179
+ notificationChannel: ComplianceNotificationChannel.Push,
3180
+ incidentSeverityIds: [CRITICAL_INCIDENT, MAJOR_INCIDENT],
3181
+ },
3182
+ {
3183
+ id: RULE_EVERY_SEVERITY,
3184
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3185
+ notificationChannel: ComplianceNotificationChannel.Email,
3186
+ },
3187
+ {
3188
+ id: RULE_PAUSED,
3189
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3190
+ notificationChannel: ComplianceNotificationChannel.Slack,
3191
+ incidentSeverityIds: [CRITICAL_INCIDENT],
3192
+ enabled: false,
3193
+ },
3194
+ {
3195
+ id: RULE_ALERT,
3196
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
3197
+ notificationChannel: ComplianceNotificationChannel.Call,
3198
+ alertSeverityIds: [CRITICAL_ALERT],
3199
+ },
3200
+ {
3201
+ // A stray severity on a method rule scopes nothing.
3202
+ id: RULE_METHOD,
3203
+ ruleType: ComplianceRuleType.HasNotificationEmailMethod,
3204
+ incidentSeverityIds: [CRITICAL_INCIDENT],
3205
+ },
3206
+ {
3207
+ id: RULE_OTHER_PROJECT,
3208
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3209
+ projectId: OTHER_PROJECT_ID,
3210
+ teamId: OTHER_TEAM_ID,
3211
+ incidentSeverityIds: [FOREIGN_INCIDENT],
3212
+ },
3213
+ {
3214
+ id: RULE_MAJOR_ONLY,
3215
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3216
+ notificationChannel: ComplianceNotificationChannel.WhatsApp,
3217
+ incidentSeverityIds: [MAJOR_INCIDENT],
3218
+ },
3219
+ );
3220
+ });
3221
+
3222
+ /*
3223
+ * ANY reference, not only rules scoped to nothing but what this delete
3224
+ * removes: two deletes side by side each still see the other's severity
3225
+ * attached, and a rule neither carried would be left empty and enabled.
3226
+ */
3227
+ test("finds every rule of the severity's kind that references a severity being deleted - paused ones too", async () => {
3228
+ expect(
3229
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3230
+ severityKind: ComplianceSeverityKind.Incident,
3231
+ severities: deleting([CRITICAL_INCIDENT]),
3232
+ }),
3233
+ ).toEqual([
3234
+ RULE_CRITICAL_ONLY,
3235
+ RULE_EPISODE_CRITICAL_ONLY,
3236
+ RULE_CRITICAL_AND_MAJOR,
3237
+ RULE_PAUSED,
3238
+ ]);
3239
+
3240
+ expect(
3241
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3242
+ severityKind: ComplianceSeverityKind.Incident,
3243
+ severities: deleting([MAJOR_INCIDENT.toUpperCase()]),
3244
+ }),
3245
+ ).toEqual([RULE_CRITICAL_AND_MAJOR, RULE_MAJOR_ONLY]);
3246
+
3247
+ expect(
3248
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3249
+ severityKind: ComplianceSeverityKind.Alert,
3250
+ severities: deleting([CRITICAL_ALERT]),
3251
+ }),
3252
+ ).toEqual([RULE_ALERT]);
3253
+
3254
+ // A severity no rule names carries nothing.
3255
+ expect(
3256
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3257
+ severityKind: ComplianceSeverityKind.Incident,
3258
+ severities: deleting([MINOR_INCIDENT]),
3259
+ }),
3260
+ ).toEqual([]);
3261
+ });
3262
+
3263
+ test("reads the project's rules of that kind's types, enabled or not, as root, with their severities", async () => {
3264
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3265
+ severityKind: ComplianceSeverityKind.Alert,
3266
+ severities: deleting([CRITICAL_ALERT]),
3267
+ });
3268
+
3269
+ expect(settingsFindBy).toHaveBeenCalledTimes(1);
3270
+
3271
+ const read: JSONObject = findByCalls()[0]!;
3272
+ const query: JSONObject = read["query"] as JSONObject;
3273
+
3274
+ expect(Object.keys(query).sort()).toEqual(["projectId", "ruleType"]);
3275
+ expect(String(query["projectId"])).toBe(PROJECT_ID.toString());
3276
+ expect((query["ruleType"] as Includes).values).toEqual([
3277
+ ComplianceRuleType.HasAlertOnCallRules,
3278
+ ComplianceRuleType.HasAlertEpisodeOnCallRules,
3279
+ ]);
3280
+ expect(read["select"]).toEqual({
3281
+ _id: true,
3282
+ ruleType: true,
3283
+ incidentSeverities: { _id: true },
3284
+ alertSeverities: { _id: true },
3285
+ });
3286
+ expect(read["limit"]).toBe(LIMIT_PER_PROJECT);
3287
+ expect(read["props"]).toEqual({ isRoot: true });
3288
+ });
3289
+
3290
+ test("a delete across projects checks each project's rules against its own severities", async () => {
3291
+ expect(
3292
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3293
+ severityKind: ComplianceSeverityKind.Incident,
3294
+ severities: [
3295
+ ...deleting([FOREIGN_INCIDENT]),
3296
+ ...deleting([FOREIGN_INCIDENT], OTHER_PROJECT_ID),
3297
+ ],
3298
+ }),
3299
+ ).toEqual([RULE_OTHER_PROJECT]);
3300
+
3301
+ expect(settingsFindBy).toHaveBeenCalledTimes(2);
3302
+ });
3303
+
3304
+ test("nothing being deleted reads nothing", async () => {
3305
+ expect(
3306
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3307
+ severityKind: ComplianceSeverityKind.Incident,
3308
+ severities: [{ _id: CRITICAL_INCIDENT }, { projectId: PROJECT_ID }],
3309
+ }),
3310
+ ).toEqual([]);
3311
+
3312
+ expect(settingsFindBy).not.toHaveBeenCalled();
3313
+ });
3314
+
3315
+ test("after the delete: pauses and marks, as root, the named rules left with no severity of their kind", async () => {
3316
+ // What the cascade leaves behind.
3317
+ storedSettings = [];
3318
+ store(
3319
+ {
3320
+ id: RULE_CRITICAL_ONLY,
3321
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3322
+ notificationChannel: ComplianceNotificationChannel.Call,
3323
+ },
3324
+ {
3325
+ id: RULE_EPISODE_CRITICAL_ONLY,
3326
+ ruleType: ComplianceRuleType.HasIncidentEpisodeOnCallRules,
3327
+ },
3328
+ {
3329
+ // Another of its severities is still there.
3330
+ id: RULE_CRITICAL_AND_MAJOR,
3331
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3332
+ incidentSeverityIds: [MAJOR_INCIDENT],
3333
+ },
3334
+ {
3335
+ // Paused by an admin before the delete: marked all the same.
3336
+ id: RULE_PAUSED,
3337
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3338
+ enabled: false,
3339
+ },
3340
+ {
3341
+ // Not of the deleted kind.
3342
+ id: RULE_ALERT,
3343
+ ruleType: ComplianceRuleType.HasAlertOnCallRules,
3344
+ },
3345
+ );
3346
+
3347
+ expect(
3348
+ await TeamComplianceSettingService.pauseRulesLeftWithoutSeverities({
3349
+ severityKind: ComplianceSeverityKind.Incident,
3350
+ settingIds: [
3351
+ RULE_CRITICAL_ONLY,
3352
+ RULE_EPISODE_CRITICAL_ONLY,
3353
+ RULE_CRITICAL_AND_MAJOR,
3354
+ RULE_PAUSED,
3355
+ RULE_ALERT,
3356
+ RULE_EVERY_SEVERITY,
3357
+ ],
3358
+ }),
3359
+ ).toEqual([RULE_CRITICAL_ONLY, RULE_EPISODE_CRITICAL_ONLY, RULE_PAUSED]);
3360
+
3361
+ const read: JSONObject = findByCalls()[0]!;
3362
+ expect((read["query"] as JSONObject)["_id"]).toBeInstanceOf(Includes);
3363
+ expect(read["select"]).toEqual({
3364
+ _id: true,
3365
+ ruleType: true,
3366
+ enabled: true,
3367
+ options: true,
3368
+ incidentSeverities: { _id: true },
3369
+ alertSeverities: { _id: true },
3370
+ });
3371
+ expect(read["props"]).toEqual({ isRoot: true });
3372
+
3373
+ // Rules with the same options are paused in one write.
3374
+ expect(settingsUpdateBy).toHaveBeenCalledTimes(1);
3375
+
3376
+ const write: JSONObject = settingsUpdateBy.mock.calls[0]![0] as JSONObject;
3377
+ expect(((write["query"] as JSONObject)["_id"] as Includes).values).toEqual([
3378
+ RULE_CRITICAL_ONLY,
3379
+ RULE_EPISODE_CRITICAL_ONLY,
3380
+ RULE_PAUSED,
3381
+ ]);
3382
+ expect(write["data"]).toEqual({ enabled: false, options: MARKED });
3383
+ expect(write["limit"]).toBe(3);
3384
+ expect(write["props"]).toEqual({ isRoot: true });
3385
+ });
3386
+
3387
+ test("the mark is added to whatever else a rule's options hold, one write per set of options", async () => {
3388
+ storedSettings = [];
3389
+ store(
3390
+ {
3391
+ id: RULE_CRITICAL_ONLY,
3392
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3393
+ options: { note: "first" },
3394
+ },
3395
+ {
3396
+ id: RULE_EPISODE_CRITICAL_ONLY,
3397
+ ruleType: ComplianceRuleType.HasIncidentEpisodeOnCallRules,
3398
+ options: { note: "second" },
3399
+ },
3400
+ );
3401
+
3402
+ await TeamComplianceSettingService.pauseRulesLeftWithoutSeverities({
3403
+ severityKind: ComplianceSeverityKind.Incident,
3404
+ settingIds: [RULE_CRITICAL_ONLY, RULE_EPISODE_CRITICAL_ONLY],
3405
+ });
3406
+
3407
+ expect(
3408
+ settingsUpdateBy.mock.calls.map((call: Array<unknown>) => {
3409
+ const write: JSONObject = call[0] as JSONObject;
3410
+ return [
3411
+ ((write["query"] as JSONObject)["_id"] as Includes).values,
3412
+ write["data"],
3413
+ ];
3414
+ }),
3415
+ ).toEqual([
3416
+ [
3417
+ [RULE_CRITICAL_ONLY],
3418
+ { enabled: false, options: { note: "first", ...MARKED } },
3419
+ ],
3420
+ [
3421
+ [RULE_EPISODE_CRITICAL_ONLY],
3422
+ { enabled: false, options: { note: "second", ...MARKED } },
3423
+ ],
3424
+ ]);
3425
+ });
3426
+
3427
+ test("a rule already paused and marked is not written again", async () => {
3428
+ storedSettings = [];
3429
+ store({
3430
+ id: RULE_CRITICAL_ONLY,
3431
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3432
+ enabled: false,
3433
+ options: MARKED,
3434
+ });
3435
+
3436
+ expect(
3437
+ await TeamComplianceSettingService.pauseRulesLeftWithoutSeverities({
3438
+ severityKind: ComplianceSeverityKind.Incident,
3439
+ settingIds: [RULE_CRITICAL_ONLY],
3440
+ }),
3441
+ ).toEqual([]);
3442
+ expect(settingsUpdateBy).not.toHaveBeenCalled();
3443
+ });
3444
+
3445
+ test("the pause write reads nothing through the update hook, so it cannot fail on the rule's configuration", async () => {
3446
+ storedSettings = [];
3447
+ store({
3448
+ id: RULE_CRITICAL_ONLY,
3449
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3450
+ });
3451
+
3452
+ const pauseWrite: UpdateBy<TeamComplianceSetting> = {
3453
+ query: { _id: new Includes([RULE_CRITICAL_ONLY]) },
3454
+ data: { enabled: false, options: MARKED },
3455
+ limit: 1,
3456
+ skip: 0,
3457
+ props: { isRoot: true },
3458
+ } as unknown as UpdateBy<TeamComplianceSetting>;
3459
+
3460
+ settingsFindBy.mockClear();
3461
+
3462
+ await onBeforeUpdate(pauseWrite);
3463
+
3464
+ expect(settingsFindBy).not.toHaveBeenCalled();
3465
+ expect(dataOf(pauseWrite)).toEqual({ enabled: false, options: MARKED });
3466
+ });
3467
+
3468
+ /*
3469
+ * Two deletes side by side, of the two severities of a "Critical and
3470
+ * Major" rule: both look the rule up before either deletes, so each sees
3471
+ * the other's severity still attached. Only the delete that finishes last
3472
+ * sees the rule empty - and it pauses it.
3473
+ */
3474
+ test("two deletes that both look up before either deletes: the rule is paused exactly once, by the last", async () => {
3475
+ const lookups: Array<Array<string>> = [
3476
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3477
+ severityKind: ComplianceSeverityKind.Incident,
3478
+ severities: deleting([CRITICAL_INCIDENT]),
3479
+ }),
3480
+ await TeamComplianceSettingService.getRulesScopedToAnyOf({
3481
+ severityKind: ComplianceSeverityKind.Incident,
3482
+ severities: deleting([MAJOR_INCIDENT]),
3483
+ }),
3484
+ ];
3485
+
3486
+ for (const lookup of lookups) {
3487
+ expect(lookup).toContain(RULE_CRITICAL_AND_MAJOR);
3488
+ }
3489
+
3490
+ // Critical's delete commits first: Major is still attached.
3491
+ storedSettings = [];
3492
+ store({
3493
+ id: RULE_CRITICAL_AND_MAJOR,
3494
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3495
+ notificationChannel: ComplianceNotificationChannel.Push,
3496
+ incidentSeverityIds: [MAJOR_INCIDENT],
3497
+ });
3498
+
3499
+ expect(
3500
+ await TeamComplianceSettingService.pauseRulesLeftWithoutSeverities({
3501
+ severityKind: ComplianceSeverityKind.Incident,
3502
+ settingIds: lookups[0]!,
3503
+ }),
3504
+ ).toEqual([]);
3505
+
3506
+ // Then Major's: nothing is left.
3507
+ storedSettings = [];
3508
+ store({
3509
+ id: RULE_CRITICAL_AND_MAJOR,
3510
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3511
+ notificationChannel: ComplianceNotificationChannel.Push,
3512
+ });
3513
+
3514
+ expect(
3515
+ await TeamComplianceSettingService.pauseRulesLeftWithoutSeverities({
3516
+ severityKind: ComplianceSeverityKind.Incident,
3517
+ settingIds: lookups[1]!,
3518
+ }),
3519
+ ).toEqual([RULE_CRITICAL_AND_MAJOR]);
3520
+
3521
+ expect(settingsUpdateBy).toHaveBeenCalledTimes(1);
3522
+ });
3523
+
3524
+ test("nothing named reads nothing; nothing left empty writes nothing", async () => {
3525
+ await TeamComplianceSettingService.pauseRulesLeftWithoutSeverities({
3526
+ severityKind: ComplianceSeverityKind.Incident,
3527
+ settingIds: [],
3528
+ });
3529
+
3530
+ expect(settingsFindBy).not.toHaveBeenCalled();
3531
+
3532
+ expect(
3533
+ await TeamComplianceSettingService.pauseRulesLeftWithoutSeverities({
3534
+ severityKind: ComplianceSeverityKind.Incident,
3535
+ settingIds: [RULE_CRITICAL_AND_MAJOR],
3536
+ }),
3537
+ ).toEqual([]);
3538
+ expect(settingsUpdateBy).not.toHaveBeenCalled();
3539
+ });
3540
+ });
3541
+
3542
+ describe("TeamComplianceSettingService.hasSeveritiesDeletedMark / isLeftWithoutSeverities", () => {
3543
+ test("only a `true` under the key is the mark", () => {
3544
+ expect(SEVERITIES_DELETED_OPTION).toBe("severitiesDeleted");
3545
+
3546
+ for (const options of [
3547
+ undefined,
3548
+ null,
3549
+ {},
3550
+ { severitiesDeleted: false },
3551
+ { severitiesDeleted: "true" },
3552
+ [true],
3553
+ "severitiesDeleted",
3554
+ ]) {
3555
+ expect(
3556
+ TeamComplianceSettingServiceClass.hasSeveritiesDeletedMark(options),
3557
+ ).toBe(false);
3558
+ }
3559
+
3560
+ expect(
3561
+ TeamComplianceSettingServiceClass.hasSeveritiesDeletedMark({
3562
+ severitiesDeleted: true,
3563
+ note: "x",
3564
+ }),
3565
+ ).toBe(true);
3566
+ });
3567
+
3568
+ test("a rule is left without severities only when marked, scoped by severity, and empty of its own kind", () => {
3569
+ const isLeft: (input: StoredRuleInput) => boolean = (
3570
+ input: StoredRuleInput,
3571
+ ): boolean => {
3572
+ return TeamComplianceSettingServiceClass.isLeftWithoutSeverities(
3573
+ storedRule(input),
3574
+ );
3575
+ };
3576
+
3577
+ expect(isLeft({ ...EMPTIED_BY_DELETE })).toBe(true);
3578
+ // Enabled or not: the mark is about the scope, not the switch.
3579
+ expect(isLeft({ ...EMPTIED_BY_DELETE, enabled: true })).toBe(true);
3580
+ // A severity of the other kind is not a scope.
3581
+ expect(
3582
+ isLeft({ ...EMPTIED_BY_DELETE, alertSeverityIds: [CRITICAL_ALERT] }),
3583
+ ).toBe(true);
3584
+ expect(
3585
+ isLeft({ ...EMPTIED_BY_DELETE, incidentSeverityIds: [MAJOR_INCIDENT] }),
3586
+ ).toBe(false);
3587
+ expect(isLeft({ ...EMPTIED_BY_DELETE, options: undefined })).toBe(false);
3588
+ expect(
3589
+ isLeft({
3590
+ ...EMPTIED_BY_DELETE,
3591
+ ruleType: ComplianceRuleType.HasNotificationCallMethod,
3592
+ }),
3593
+ ).toBe(false);
3594
+ expect(isLeft({ ...EMPTIED_BY_DELETE, ruleType: "LegacyRule" })).toBe(
3595
+ false,
3596
+ );
3597
+ });
3598
+ });
3599
+
3600
+ /*
3601
+ * ---------------------------------------------------------------------------
3602
+ * The static scope helpers.
3603
+ * ---------------------------------------------------------------------------
3604
+ */
3605
+
3606
+ describe("TeamComplianceSettingService.getIds", () => {
3607
+ test.each([
3608
+ ["undefined", undefined],
3609
+ ["null", null],
3610
+ ["a bare id string", CRITICAL_INCIDENT],
3611
+ ["an object", { _id: CRITICAL_INCIDENT }],
3612
+ ["a number", 7],
3613
+ ])(
3614
+ "is empty for %s, which is not a list",
3615
+ (_label: string, value: unknown) => {
3616
+ expect(TeamComplianceSettingServiceClass.getIds(value)).toEqual([]);
3617
+ },
3618
+ );
3619
+
3620
+ test("reads ids from strings, ObjectIDs, models and {_id} / {id} JSON", () => {
3621
+ const model: AlertSeverity = new AlertSeverity();
3622
+ model._id = MAJOR_ALERT;
3623
+
3624
+ expect(
3625
+ TeamComplianceSettingServiceClass.getIds([
3626
+ CRITICAL_INCIDENT,
3627
+ new ObjectID(MAJOR_INCIDENT),
3628
+ { _id: MINOR_INCIDENT },
3629
+ { id: CRITICAL_ALERT },
3630
+ { _id: new ObjectID(FOREIGN_ALERT) },
3631
+ { id: new ObjectID(FOREIGN_INCIDENT) },
3632
+ model,
3633
+ ]),
3634
+ ).toEqual(
3635
+ [
3636
+ CRITICAL_INCIDENT,
3637
+ MAJOR_INCIDENT,
3638
+ MINOR_INCIDENT,
3639
+ CRITICAL_ALERT,
3640
+ FOREIGN_ALERT,
3641
+ FOREIGN_INCIDENT,
3642
+ MAJOR_ALERT,
3643
+ ].sort(),
3644
+ );
3645
+ });
3646
+
3647
+ test("prefers _id over id when an item carries both", () => {
3648
+ expect(
3649
+ TeamComplianceSettingServiceClass.getIds([
3650
+ { _id: CRITICAL_INCIDENT, id: MAJOR_INCIDENT },
3651
+ ]),
3652
+ ).toEqual([CRITICAL_INCIDENT]);
3653
+ });
3654
+
3655
+ test("skips items with no usable id", () => {
3656
+ expect(
3657
+ TeamComplianceSettingServiceClass.getIds([
3658
+ null,
3659
+ undefined,
3660
+ "",
3661
+ " ",
3662
+ 12,
3663
+ true,
3664
+ {},
3665
+ { _id: 5 },
3666
+ { name: "Critical" },
3667
+ [CRITICAL_INCIDENT],
3668
+ CRITICAL_INCIDENT,
3669
+ ]),
3670
+ ).toEqual([CRITICAL_INCIDENT]);
3671
+ });
3672
+
3673
+ test("trims, de-duplicates and sorts, so two lists compare by content", () => {
3674
+ expect(
3675
+ TeamComplianceSettingServiceClass.getIds([
3676
+ MINOR_INCIDENT,
3677
+ ` ${CRITICAL_INCIDENT} `,
3678
+ MAJOR_INCIDENT,
3679
+ CRITICAL_INCIDENT,
3680
+ ]),
3681
+ ).toEqual([CRITICAL_INCIDENT, MAJOR_INCIDENT, MINOR_INCIDENT].sort());
3682
+ });
3683
+
3684
+ test("lower-cases, because Postgres stores and compares uuids without case", () => {
3685
+ expect(
3686
+ TeamComplianceSettingServiceClass.getIds([
3687
+ CRITICAL_INCIDENT.toUpperCase(),
3688
+ CRITICAL_INCIDENT,
3689
+ { _id: MAJOR_INCIDENT.toUpperCase() },
3690
+ ]),
3691
+ ).toEqual([CRITICAL_INCIDENT, MAJOR_INCIDENT]);
3692
+ });
3693
+ });
3694
+
3695
+ describe("TeamComplianceSettingService.getScope", () => {
3696
+ test.each(METHOD_RULE_TYPES)(
3697
+ "%s has no channel, no severity kind and no severities, whatever is passed",
3698
+ (ruleType: ComplianceRuleType) => {
3699
+ expect(
3700
+ TeamComplianceSettingServiceClass.getScope({
3701
+ ruleType: ruleType,
3702
+ notificationChannel: ComplianceNotificationChannel.Call,
3703
+ incidentSeverities: [CRITICAL_INCIDENT],
3704
+ alertSeverities: [CRITICAL_ALERT],
3705
+ }),
3706
+ ).toEqual({
3707
+ ruleType: ruleType,
3708
+ notificationChannel: null,
3709
+ severityKind: null,
3710
+ severityIds: [],
3711
+ });
3712
+ },
3713
+ );
3714
+
3715
+ test.each(INCIDENT_RULE_TYPES)(
3716
+ "%s is scoped by its incident severities only",
3717
+ (ruleType: ComplianceRuleType) => {
3718
+ expect(
3719
+ TeamComplianceSettingServiceClass.getScope({
3720
+ ruleType: ruleType,
3721
+ notificationChannel: ComplianceNotificationChannel.Call,
3722
+ incidentSeverities: [MAJOR_INCIDENT, CRITICAL_INCIDENT],
3723
+ alertSeverities: [CRITICAL_ALERT],
3724
+ }),
3725
+ ).toEqual({
3726
+ ruleType: ruleType,
3727
+ notificationChannel: ComplianceNotificationChannel.Call,
3728
+ severityKind: ComplianceSeverityKind.Incident,
3729
+ severityIds: [CRITICAL_INCIDENT, MAJOR_INCIDENT].sort(),
3730
+ });
3731
+ },
3732
+ );
3733
+
3734
+ test.each(ALERT_RULE_TYPES)(
3735
+ "%s is scoped by its alert severities only",
3736
+ (ruleType: ComplianceRuleType) => {
3737
+ expect(
3738
+ TeamComplianceSettingServiceClass.getScope({
3739
+ ruleType: ruleType,
3740
+ notificationChannel: null,
3741
+ incidentSeverities: [CRITICAL_INCIDENT],
3742
+ alertSeverities: [MAJOR_ALERT],
3743
+ }),
3744
+ ).toEqual({
3745
+ ruleType: ruleType,
3746
+ notificationChannel: null,
3747
+ severityKind: ComplianceSeverityKind.Alert,
3748
+ severityIds: [MAJOR_ALERT],
3749
+ });
3750
+ },
3751
+ );
3752
+
3753
+ test.each([
3754
+ ["undefined", undefined],
3755
+ ["null", null],
3756
+ ["an unknown channel", "Pager"],
3757
+ ["a channel in the wrong case", "call"],
3758
+ ["a number", 3],
3759
+ ])("reads %s as 'any channel'", (_label: string, channel: unknown) => {
3760
+ expect(
3761
+ TeamComplianceSettingServiceClass.getScope({
3762
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3763
+ notificationChannel: channel,
3764
+ incidentSeverities: undefined,
3765
+ alertSeverities: undefined,
3766
+ }).notificationChannel,
3767
+ ).toBeNull();
3768
+ });
3769
+
3770
+ test.each(
3771
+ COMPLIANCE_RULE_DEFINITIONS.map(
3772
+ (definition: ComplianceRuleDefinition): [ComplianceRuleType] => {
3773
+ return [definition.ruleType];
3774
+ },
3775
+ ),
3776
+ )(
3777
+ "%s: the scope agrees with the rule catalog",
3778
+ (ruleType: ComplianceRuleType) => {
3779
+ const scope: ComplianceRuleScope =
3780
+ TeamComplianceSettingServiceClass.getScope({
3781
+ ruleType: ruleType,
3782
+ notificationChannel: ComplianceNotificationChannel.Slack,
3783
+ incidentSeverities: [CRITICAL_INCIDENT],
3784
+ alertSeverities: [CRITICAL_ALERT],
3785
+ });
3786
+
3787
+ expect(scope.severityKind).toBe(
3788
+ ComplianceRule.getSeverityKind(ruleType) || null,
3789
+ );
3790
+ expect(scope.notificationChannel).toBe(
3791
+ ComplianceRule.supportsChannel(ruleType)
3792
+ ? ComplianceNotificationChannel.Slack
3793
+ : null,
3794
+ );
3795
+ expect(scope.severityIds).toHaveLength(
3796
+ ComplianceRule.supportsSeverityScope(ruleType) ? 1 : 0,
3797
+ );
3798
+ },
3799
+ );
3800
+ });
3801
+
3802
+ describe("TeamComplianceSettingService.isSameScope", () => {
3803
+ const base: ComplianceRuleScope = TeamComplianceSettingServiceClass.getScope({
3804
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3805
+ notificationChannel: ComplianceNotificationChannel.Call,
3806
+ incidentSeverities: [CRITICAL_INCIDENT, MAJOR_INCIDENT],
3807
+ alertSeverities: undefined,
3808
+ });
3809
+
3810
+ test("a scope is the same as itself and as an identical copy", () => {
3811
+ expect(TeamComplianceSettingServiceClass.isSameScope(base, base)).toBe(
3812
+ true,
3813
+ );
3814
+ expect(
3815
+ TeamComplianceSettingServiceClass.isSameScope(base, {
3816
+ ...base,
3817
+ severityIds: [...base.severityIds],
3818
+ }),
3819
+ ).toBe(true);
3820
+ });
3821
+
3822
+ test("severity order, repeats and case do not matter once read through getScope", () => {
3823
+ const reordered: ComplianceRuleScope =
3824
+ TeamComplianceSettingServiceClass.getScope({
3825
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3826
+ notificationChannel: ComplianceNotificationChannel.Call,
3827
+ incidentSeverities: [
3828
+ MAJOR_INCIDENT.toUpperCase(),
3829
+ { _id: CRITICAL_INCIDENT },
3830
+ MAJOR_INCIDENT,
3831
+ ],
3832
+ alertSeverities: [CRITICAL_ALERT],
3833
+ });
3834
+
3835
+ expect(TeamComplianceSettingServiceClass.isSameScope(base, reordered)).toBe(
3836
+ true,
3837
+ );
3838
+ expect(TeamComplianceSettingServiceClass.isSameScope(reordered, base)).toBe(
3839
+ true,
3840
+ );
3841
+ });
3842
+
3843
+ test.each<[string, Partial<ComplianceRuleScope>]>([
3844
+ [
3845
+ "the rule type",
3846
+ { ruleType: ComplianceRuleType.HasIncidentEpisodeOnCallRules },
3847
+ ],
3848
+ [
3849
+ "the channel",
3850
+ { notificationChannel: ComplianceNotificationChannel.Push },
3851
+ ],
3852
+ ["'any channel' against one channel", { notificationChannel: null }],
3853
+ ["a narrower severity set", { severityIds: [CRITICAL_INCIDENT] }],
3854
+ ["every severity against some", { severityIds: [] }],
3855
+ [
3856
+ "a wider severity set",
3857
+ {
3858
+ severityIds: [CRITICAL_INCIDENT, MAJOR_INCIDENT, MINOR_INCIDENT].sort(),
3859
+ },
3860
+ ],
3861
+ ])(
3862
+ "differs by %s",
3863
+ (_label: string, change: Partial<ComplianceRuleScope>) => {
3864
+ const other: ComplianceRuleScope = { ...base, ...change };
3865
+
3866
+ expect(TeamComplianceSettingServiceClass.isSameScope(base, other)).toBe(
3867
+ false,
3868
+ );
3869
+ expect(TeamComplianceSettingServiceClass.isSameScope(other, base)).toBe(
3870
+ false,
3871
+ );
3872
+ },
3873
+ );
3874
+ });
3875
+
3876
+ /*
3877
+ * ---------------------------------------------------------------------------
3878
+ * Through updateOneById, the path BaseAPI takes.
3879
+ * ---------------------------------------------------------------------------
3880
+ */
3881
+
3882
+ type InternalFindFunction = (
3883
+ findBy: JSONObject,
3884
+ ) => Promise<Array<TeamComplianceSetting>>;
3885
+
3886
+ interface ServiceInternals {
3887
+ _findBy: InternalFindFunction;
3888
+ }
3889
+
3890
+ describe("TeamComplianceSettingService updates reach the write normalised", () => {
3891
+ let service: TeamComplianceSettingServiceClass;
3892
+ let save: jest.Mock;
3893
+ let repositoryUpdate: jest.Mock;
3894
+
3895
+ beforeEach(() => {
3896
+ service = new TeamComplianceSettingServiceClass();
3897
+
3898
+ store(INCIDENT_CALL_CRITICAL);
3899
+
3900
+ // The hook's reads, from the in-memory team.
3901
+ jest.spyOn(service, "findBy").mockImplementation(((findBy: {
3902
+ query: JSONObject;
3903
+ }) => {
3904
+ return Promise.resolve(
3905
+ storedSettings.filter((row: TeamComplianceSetting): boolean => {
3906
+ return matchesQuery(row, findBy.query);
3907
+ }),
3908
+ );
3909
+ }) as never);
3910
+
3911
+ // _updateBy's own internal find of the row it is about to write.
3912
+ jest
3913
+ .spyOn(service as unknown as ServiceInternals, "_findBy")
3914
+ .mockImplementation((): Promise<Array<TeamComplianceSetting>> => {
3915
+ const row: TeamComplianceSetting = new TeamComplianceSetting();
3916
+ row._id = SETTING_ID;
3917
+ row.projectId = PROJECT_ID;
3918
+ return Promise.resolve([row]);
3919
+ });
3920
+
3921
+ save = jest.fn((item: unknown) => {
3922
+ return Promise.resolve(item);
3923
+ });
3924
+ repositoryUpdate = jest.fn(() => {
3925
+ return Promise.resolve({ affected: 1 });
3926
+ });
3927
+ jest.spyOn(service, "getRepository").mockReturnValue({
3928
+ save: save,
3929
+ update: repositoryUpdate,
3930
+ } as never);
3931
+
3932
+ // The permission layer needs a database and is not what this is about.
3933
+ jest
3934
+ .spyOn(ModelPermission, "checkUpdateQueryPermissions")
3935
+ .mockImplementation(((_modelType: unknown, query: unknown) => {
3936
+ return Promise.resolve(query);
3937
+ }) as never);
3938
+ jest
3939
+ .spyOn(ModelPermission, "checkUpdatePermissionByModel")
3940
+ .mockResolvedValue(undefined as never);
3941
+ });
3942
+
3943
+ test("a toggle is a plain column update: no hook reads, no relation write", async () => {
3944
+ await service.updateOneById({
3945
+ id: new ObjectID(SETTING_ID),
3946
+ data: { enabled: false },
3947
+ props: { isRoot: true },
3948
+ });
3949
+
3950
+ expect(service.findBy).not.toHaveBeenCalled();
3951
+ expect(save).not.toHaveBeenCalled();
3952
+ expect(repositoryUpdate).toHaveBeenCalledTimes(1);
3953
+ expect(repositoryUpdate.mock.calls[0]?.[1]).toMatchObject({
3954
+ enabled: false,
3955
+ });
3956
+ });
3957
+
3958
+ test("a rule type change writes the cleared channel and severities with the new type", async () => {
3959
+ await service.updateOneById({
3960
+ id: new ObjectID(SETTING_ID),
3961
+ data: { ruleType: ComplianceRuleType.HasNotificationEmailMethod },
3962
+ props: { isRoot: true },
3963
+ });
3964
+
3965
+ // The severity lists are relations, so the row goes through save().
3966
+ expect(save).toHaveBeenCalledTimes(1);
3967
+
3968
+ const written: Record<string, unknown> = save.mock.calls[0]?.[0] as Record<
3969
+ string,
3970
+ unknown
3971
+ >;
3972
+ expect(written["_id"]).toBe(SETTING_ID);
3973
+ expect(written["ruleType"]).toBe(
3974
+ ComplianceRuleType.HasNotificationEmailMethod,
3975
+ );
3976
+ expect(written["notificationChannel"]).toBeNull();
3977
+ expect(written["incidentSeverities"]).toEqual([]);
3978
+ expect(written["alertSeverities"]).toEqual([]);
3979
+ });
3980
+
3981
+ test("a duplicate is refused before anything is written", async () => {
3982
+ store({
3983
+ id: SIBLING_SETTING_ID,
3984
+ ruleType: ComplianceRuleType.HasIncidentOnCallRules,
3985
+ notificationChannel: ComplianceNotificationChannel.Push,
3986
+ incidentSeverityIds: [CRITICAL_INCIDENT],
3987
+ });
3988
+
3989
+ await expect(
3990
+ service.updateOneById({
3991
+ id: new ObjectID(SETTING_ID),
3992
+ data: { notificationChannel: ComplianceNotificationChannel.Push },
3993
+ props: { isRoot: true },
3994
+ }),
3995
+ ).rejects.toThrow(DUPLICATE_COMPLIANCE_RULE_MESSAGE);
3996
+
3997
+ expect(save).not.toHaveBeenCalled();
3998
+ expect(repositoryUpdate).not.toHaveBeenCalled();
3999
+ });
4000
+ test("a repeated severity, in any case, reaches the relation save once and lower-cased", async () => {
4001
+ await service.updateOneById({
4002
+ id: new ObjectID(SETTING_ID),
4003
+ data: {
4004
+ incidentSeverities: [
4005
+ { _id: MAJOR_INCIDENT.toUpperCase() },
4006
+ { _id: MAJOR_INCIDENT },
4007
+ ],
4008
+ } as never,
4009
+ props: { isRoot: true },
4010
+ });
4011
+
4012
+ expect(save).toHaveBeenCalledTimes(1);
4013
+
4014
+ const written: Record<string, unknown> = save.mock.calls[0]?.[0] as Record<
4015
+ string,
4016
+ unknown
4017
+ >;
4018
+ expect(
4019
+ (written["incidentSeverities"] as Array<{ _id?: string }>).map(
4020
+ (item: { _id?: string }): string | undefined => {
4021
+ return item._id;
4022
+ },
4023
+ ),
4024
+ ).toEqual([MAJOR_INCIDENT]);
4025
+ });
4026
+
4027
+ test("another project's severity named by `id` behind a non-string `_id` is refused before anything is written", async () => {
4028
+ await expect(
4029
+ service.updateOneById({
4030
+ id: new ObjectID(SETTING_ID),
4031
+ data: {
4032
+ incidentSeverities: [{ _id: 1, id: FOREIGN_INCIDENT }],
4033
+ } as never,
4034
+ props: EDITOR_PROPS,
4035
+ }),
4036
+ ).rejects.toThrow(FOREIGN_INCIDENT_SEVERITY_MESSAGE);
4037
+
4038
+ expect(save).not.toHaveBeenCalled();
4039
+ expect(repositoryUpdate).not.toHaveBeenCalled();
190
4040
  });
191
4041
  });