@oneuptime/common 12.0.23 → 12.0.24

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 (294) hide show
  1. package/Models/DatabaseModels/DetectionRule.ts +55 -0
  2. package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +27 -7
  3. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +67 -0
  4. package/Models/DatabaseModels/Project.ts +68 -0
  5. package/Models/DatabaseModels/User.ts +68 -0
  6. package/Server/API/TelemetryAPI.ts +29 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1789400000000-AddNameToNetworkDeviceDiscoveryScan.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1789500000000-AddCampaignUtmFields.ts +49 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1789512000000-AddDetectionRuleDistinctCountColumns.ts +33 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  11. package/Server/Middleware/GzipRequestBody.ts +311 -0
  12. package/Server/Middleware/MultipartFormData.ts +87 -3
  13. package/Server/Services/DetectionRuleService.ts +73 -6
  14. package/Server/Services/Index.ts +205 -0
  15. package/Server/Services/NetworkDeviceDiscoveryScanService.ts +59 -0
  16. package/Server/Services/NetworkDeviceService.ts +148 -0
  17. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +65 -0
  18. package/Server/Services/OnCallDutyPolicyFeedService.ts +1 -1
  19. package/Server/Services/ProjectService.ts +29 -19
  20. package/Server/Services/SecurityEventService.ts +90 -13
  21. package/Server/Services/StatusPageResourceService.ts +223 -0
  22. package/Server/Services/TelemetryAttributeService.ts +17 -0
  23. package/Server/Services/UserService.ts +4 -6
  24. package/Server/Types/Markdown.ts +161 -67
  25. package/Server/Utils/AI/Toolbox/OnCallTools.ts +12 -4
  26. package/Server/Utils/AnalyticsDatabase/Statement.ts +13 -1
  27. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +297 -6
  28. package/Server/Utils/Attribution.ts +4 -0
  29. package/Server/Utils/Marketing/MarketingEventUtil.ts +45 -7
  30. package/Server/Utils/SecurityEvent/DetectionRuleEvaluator.ts +55 -3
  31. package/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.ts +21 -0
  32. package/Server/Utils/StartServer.ts +12 -32
  33. package/Server/Utils/Telemetry/LlmCostBudgetEvaluator.ts +101 -4
  34. package/Server/Utils/Telemetry/LlmMetricSpend.ts +130 -0
  35. package/Server/Views/Partials/AnalyticsConsent.ejs +159 -102
  36. package/Server/Views/Partials/Head.ejs +2 -1
  37. package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +776 -38
  38. package/Tests/App/Dashboard/CorrelateFilterBuilder.test.tsx +359 -0
  39. package/Tests/App/Dashboard/CorrelateGraph.test.tsx +865 -0
  40. package/Tests/App/Dashboard/CorrelationGraph.test.ts +346 -0
  41. package/Tests/App/Dashboard/CriteriaFilterConditionDropdown.test.tsx +256 -0
  42. package/Tests/App/Dashboard/CriteriaFilterDefaults.test.ts +738 -0
  43. package/Tests/App/Dashboard/CriteriaFilterMonitorTypeChange.test.ts +1122 -0
  44. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +283 -0
  45. package/Tests/App/Dashboard/ExternalStatusPageCriteriaFilter.test.ts +407 -0
  46. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +5 -14
  47. package/Tests/App/Dashboard/MonitorCriteriaActionToggles.test.tsx +426 -0
  48. package/Tests/App/Dashboard/MonitorStepsCriteriaAlignmentWiring.test.tsx +352 -0
  49. package/Tests/App/Dashboard/OnCallLayerCardOverrides.test.tsx +278 -0
  50. package/Tests/App/Dashboard/OnCallLayerShiftPreviewOverrides.test.ts +237 -0
  51. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +2 -2
  52. package/Tests/App/Dashboard/OnCallSchedulePreviewOverrides.test.tsx +460 -0
  53. package/Tests/App/Dashboard/SecurityEventAttributeUtil.test.ts +171 -0
  54. package/Tests/App/Dashboard/SecurityEventCorrelation.test.ts +857 -0
  55. package/Tests/App/Dashboard/SecurityEventDetailObservables.test.tsx +194 -0
  56. package/Tests/App/Dashboard/SecurityEventsDetectionRulesPage.test.tsx +124 -0
  57. package/Tests/App/Dashboard/SecurityEventsMonitorStepForm.test.tsx +79 -19
  58. package/Tests/App/Dashboard/SecurityEventsTableColumns.test.tsx +382 -0
  59. package/Tests/App/StatusPage/BulkAddStatusPageMonitors.test.ts +216 -0
  60. package/Tests/App/StatusPage/BulkAddStatusPageMonitorsModal.test.tsx +590 -11
  61. package/Tests/App/StatusPage/StatusPageResourceExplorer.test.tsx +757 -0
  62. package/Tests/Models/DiscoveryScanNameColumn.test.ts +293 -0
  63. package/Tests/Server/API/SecurityEventAttributesAPI.test.ts +564 -0
  64. package/Tests/Server/Infrastructure/Postgres/SchemaMigrationsOrdering.test.ts +120 -4
  65. package/Tests/Server/Middleware/GzipRequestBody.test.ts +725 -0
  66. package/Tests/Server/Middleware/MultipartFormData.test.ts +298 -0
  67. package/Tests/Server/Services/AddDetectionRuleDistinctCountColumnsMigration.test.ts +311 -0
  68. package/Tests/Server/Services/DetectionRuleService.test.ts +134 -2
  69. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +7 -1
  70. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +131 -0
  71. package/Tests/Server/Services/NetworkDeviceDiscoveryScanService.test.ts +194 -0
  72. package/Tests/Server/Services/NetworkDeviceMonitorStatusStamp.test.ts +800 -0
  73. package/Tests/Server/Services/OnCallScheduleAttachRosterRefresh.test.ts +244 -0
  74. package/Tests/Server/Services/SecurityEventService.test.ts +376 -0
  75. package/Tests/Server/Services/StatusPageResourceDuplicates.test.ts +703 -0
  76. package/Tests/Server/Services/TelemetryAttributeServiceSecurityEvent.test.ts +256 -0
  77. package/Tests/Server/Types/Workflow/Components/ComponentRegistryParity.test.ts +417 -0
  78. package/Tests/Server/Utils/AnalyticsDatabase/StatementGeneratorWhereOperators.test.ts +677 -0
  79. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +43 -0
  80. package/Tests/Server/Utils/Monitor/Criteria/ProfileMonitorCriteria.test.ts +15 -11
  81. package/Tests/Server/Utils/SecurityEvent/DetectionRuleEvaluator.test.ts +369 -8
  82. package/Tests/Server/Utils/SecurityEvent/SigmaClickhouseCompiler.test.ts +35 -0
  83. package/Tests/Server/Utils/Telemetry/LlmCostBudgetEvaluator.test.ts +368 -4
  84. package/Tests/Server/Utils/Telemetry/LlmMetricSpend.test.ts +245 -0
  85. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +48 -5
  86. package/Tests/Types/Code/YamlSyntax.test.ts +396 -0
  87. package/Tests/Types/Monitor/MetricAndProfileDefaultCriteria.test.ts +957 -0
  88. package/Tests/Types/Monitor/MonitorCriteriaActionValidation.test.ts +598 -0
  89. package/Tests/Types/Monitor/MonitorCriteriaInstance.test.ts +49 -0
  90. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +6 -6
  91. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationSeverityMapper.test.ts +4 -4
  92. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +4 -3
  93. package/Tests/Types/Telemetry/LlmMetricConventions.test.ts +193 -0
  94. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +0 -12
  95. package/Tests/UI/Components/CardModelDetailEdit.test.tsx +1 -17
  96. package/Tests/UI/Components/CodeEditor.test.tsx +474 -10
  97. package/Tests/UI/Components/Detail/YamlField.test.tsx +105 -0
  98. package/Tests/UI/Components/EntityDropdownLabelsBulkAdd.test.tsx +319 -0
  99. package/Tests/UI/Components/Forms/FormFieldYaml.test.tsx +231 -0
  100. package/Tests/UI/Components/Forms/Utils/FormFieldSchemaTypeUtil.test.ts +1 -0
  101. package/Tests/UI/Components/Forms/ValidationYAML.test.ts +234 -0
  102. package/Tests/UI/Components/ModelTable/AttributeColumns.test.tsx +573 -0
  103. package/Tests/UI/Components/ModelTable/BaseModelTableAttributeColumns.test.tsx +824 -0
  104. package/Tests/UI/Components/ModelTable/ColumnCustomizationModalAddableColumns.test.tsx +707 -0
  105. package/Tests/UI/Components/SideOver.test.tsx +1 -1
  106. package/Tests/UI/Components/StatusPage/ResourceGridSelection.test.tsx +294 -0
  107. package/Tests/UI/Components/StatusPage/ResourceListSelection.test.tsx +436 -0
  108. package/Tests/UI/Components/TableBulkCsvExport.test.tsx +6 -2
  109. package/Tests/UI/Components/Toggle.test.tsx +25 -0
  110. package/Tests/UI/Components/YamlEditor.test.tsx +847 -0
  111. package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +252 -0
  112. package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +221 -0
  113. package/Tests/Utils/NetworkDiscovery/ScanNameUtil.test.ts +312 -0
  114. package/Tests/Utils/StatusPage/ResourceExplorer.test.ts +468 -0
  115. package/Tests/Utils/Telemetry/LlmMetricQuery.test.ts +472 -0
  116. package/Types/Code/YamlSyntax.ts +396 -0
  117. package/Types/Exception/ExceptionCode.ts +1 -0
  118. package/Types/Exception/PayloadTooLargeException.ts +8 -0
  119. package/Types/Marketing/Attribution.ts +10 -0
  120. package/Types/Marketing/MarketingEvent.ts +4 -0
  121. package/Types/Monitor/MonitorCriteriaInstance.ts +116 -56
  122. package/Types/Monitor/Recommendation/MonitorRecommendationSeverityMapper.ts +2 -1
  123. package/Types/SecurityEvent/DetectionFindingConstants.ts +11 -0
  124. package/Types/Telemetry/LlmMetricConventions.ts +124 -0
  125. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +3 -0
  126. package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +10 -1
  127. package/UI/Components/CodeEditor/CodeEditor.tsx +241 -79
  128. package/UI/Components/CodeEditor/YamlEditor.tsx +369 -0
  129. package/UI/Components/Detail/Detail.tsx +10 -0
  130. package/UI/Components/EntityDropdown/EntityDropdown.tsx +33 -0
  131. package/UI/Components/Forms/Fields/FormField.tsx +31 -0
  132. package/UI/Components/Forms/Types/Field.ts +8 -0
  133. package/UI/Components/Forms/Types/FormFieldSchemaType.ts +1 -0
  134. package/UI/Components/Forms/Utils/FormFieldSchemaTypeUtil.ts +2 -0
  135. package/UI/Components/Forms/Validation.ts +46 -0
  136. package/UI/Components/Input/Input.tsx +3 -0
  137. package/UI/Components/ModelTable/AttributeColumns.tsx +321 -0
  138. package/UI/Components/ModelTable/BaseModelTable.tsx +263 -14
  139. package/UI/Components/ModelTable/Column.ts +8 -0
  140. package/UI/Components/ModelTable/ColumnCustomizationModal.tsx +310 -0
  141. package/UI/Components/ModelTable/ColumnPreference.ts +13 -1
  142. package/UI/Components/MonitorTemplateVariables/TemplateVariablesModal.tsx +2 -2
  143. package/UI/Components/SideOver/SideOver.tsx +5 -5
  144. package/UI/Components/StatusPage/ResourceGrid.tsx +82 -2
  145. package/UI/Components/StatusPage/ResourceList.tsx +197 -38
  146. package/UI/Components/Toggle/Toggle.tsx +9 -1
  147. package/UI/Components/Types/FieldType.ts +1 -0
  148. package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +73 -1
  149. package/Utils/NetworkDiscovery/ScanNameUtil.ts +181 -0
  150. package/Utils/StatusPage/ResourceExplorer.ts +258 -0
  151. package/Utils/Telemetry/LlmMetricQuery.ts +204 -0
  152. package/build/dist/Models/DatabaseModels/DetectionRule.js +57 -0
  153. package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
  154. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +27 -7
  155. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -1
  156. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +69 -0
  157. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  158. package/build/dist/Models/DatabaseModels/Project.js +76 -0
  159. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  160. package/build/dist/Models/DatabaseModels/User.js +76 -0
  161. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  162. package/build/dist/Server/API/TelemetryAPI.js +17 -0
  163. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  164. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789400000000-AddNameToNetworkDeviceDiscoveryScan.js +21 -0
  165. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789400000000-AddNameToNetworkDeviceDiscoveryScan.js.map +1 -0
  166. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789500000000-AddCampaignUtmFields.js +42 -0
  167. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789500000000-AddCampaignUtmFields.js.map +1 -0
  168. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789512000000-AddDetectionRuleDistinctCountColumns.js +22 -0
  169. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789512000000-AddDetectionRuleDistinctCountColumns.js.map +1 -0
  170. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  171. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  172. package/build/dist/Server/Middleware/GzipRequestBody.js +224 -0
  173. package/build/dist/Server/Middleware/GzipRequestBody.js.map +1 -0
  174. package/build/dist/Server/Middleware/MultipartFormData.js +71 -2
  175. package/build/dist/Server/Middleware/MultipartFormData.js.map +1 -1
  176. package/build/dist/Server/Services/DetectionRuleService.js +49 -4
  177. package/build/dist/Server/Services/DetectionRuleService.js.map +1 -1
  178. package/build/dist/Server/Services/Index.js +204 -0
  179. package/build/dist/Server/Services/Index.js.map +1 -1
  180. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js +50 -0
  181. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js.map +1 -1
  182. package/build/dist/Server/Services/NetworkDeviceService.js +124 -0
  183. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  184. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +68 -13
  185. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  186. package/build/dist/Server/Services/OnCallDutyPolicyFeedService.js +1 -1
  187. package/build/dist/Server/Services/OnCallDutyPolicyFeedService.js.map +1 -1
  188. package/build/dist/Server/Services/ProjectService.js +25 -45
  189. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  190. package/build/dist/Server/Services/SecurityEventService.js +57 -5
  191. package/build/dist/Server/Services/SecurityEventService.js.map +1 -1
  192. package/build/dist/Server/Services/StatusPageResourceService.js +140 -0
  193. package/build/dist/Server/Services/StatusPageResourceService.js.map +1 -1
  194. package/build/dist/Server/Services/TelemetryAttributeService.js +17 -0
  195. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  196. package/build/dist/Server/Services/UserService.js +2 -12
  197. package/build/dist/Server/Services/UserService.js.map +1 -1
  198. package/build/dist/Server/Types/Markdown.js +147 -61
  199. package/build/dist/Server/Types/Markdown.js.map +1 -1
  200. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +12 -4
  201. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -1
  202. package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js +12 -1
  203. package/build/dist/Server/Utils/AnalyticsDatabase/Statement.js.map +1 -1
  204. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +246 -9
  205. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  206. package/build/dist/Server/Utils/Attribution.js.map +1 -1
  207. package/build/dist/Server/Utils/Marketing/MarketingEventUtil.js +27 -7
  208. package/build/dist/Server/Utils/Marketing/MarketingEventUtil.js.map +1 -1
  209. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js +42 -7
  210. package/build/dist/Server/Utils/SecurityEvent/DetectionRuleEvaluator.js.map +1 -1
  211. package/build/dist/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.js +18 -0
  212. package/build/dist/Server/Utils/SecurityEvent/Sigma/SigmaClickhouseCompiler.js.map +1 -1
  213. package/build/dist/Server/Utils/StartServer.js +12 -21
  214. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  215. package/build/dist/Server/Utils/Telemetry/LlmCostBudgetEvaluator.js +76 -5
  216. package/build/dist/Server/Utils/Telemetry/LlmCostBudgetEvaluator.js.map +1 -1
  217. package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js +102 -0
  218. package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js.map +1 -0
  219. package/build/dist/Types/Code/YamlSyntax.js +212 -0
  220. package/build/dist/Types/Code/YamlSyntax.js.map +1 -0
  221. package/build/dist/Types/Exception/ExceptionCode.js +1 -0
  222. package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
  223. package/build/dist/Types/Exception/PayloadTooLargeException.js +8 -0
  224. package/build/dist/Types/Exception/PayloadTooLargeException.js.map +1 -0
  225. package/build/dist/Types/Marketing/Attribution.js +10 -0
  226. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  227. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +112 -51
  228. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  229. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationSeverityMapper.js +2 -1
  230. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationSeverityMapper.js.map +1 -1
  231. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js +9 -0
  232. package/build/dist/Types/SecurityEvent/DetectionFindingConstants.js.map +1 -1
  233. package/build/dist/Types/Telemetry/LlmMetricConventions.js +98 -0
  234. package/build/dist/Types/Telemetry/LlmMetricConventions.js.map +1 -0
  235. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
  236. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  237. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +5 -2
  238. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
  239. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +206 -76
  240. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  241. package/build/dist/UI/Components/CodeEditor/YamlEditor.js +201 -0
  242. package/build/dist/UI/Components/CodeEditor/YamlEditor.js.map +1 -0
  243. package/build/dist/UI/Components/Detail/Detail.js +9 -0
  244. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  245. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +16 -1
  246. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -1
  247. package/build/dist/UI/Components/Forms/Fields/FormField.js +14 -1
  248. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  249. package/build/dist/UI/Components/Forms/Types/Field.js.map +1 -1
  250. package/build/dist/UI/Components/Forms/Types/FormFieldSchemaType.js +1 -0
  251. package/build/dist/UI/Components/Forms/Types/FormFieldSchemaType.js.map +1 -1
  252. package/build/dist/UI/Components/Forms/Utils/FormFieldSchemaTypeUtil.js +2 -0
  253. package/build/dist/UI/Components/Forms/Utils/FormFieldSchemaTypeUtil.js.map +1 -1
  254. package/build/dist/UI/Components/Forms/Validation.js +27 -0
  255. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  256. package/build/dist/UI/Components/Input/Input.js +1 -1
  257. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  258. package/build/dist/UI/Components/ModelTable/AttributeColumns.js +159 -0
  259. package/build/dist/UI/Components/ModelTable/AttributeColumns.js.map +1 -0
  260. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +179 -17
  261. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  262. package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js +133 -2
  263. package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js.map +1 -1
  264. package/build/dist/UI/Components/ModelTable/ColumnPreference.js +7 -1
  265. package/build/dist/UI/Components/ModelTable/ColumnPreference.js.map +1 -1
  266. package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesModal.js +2 -2
  267. package/build/dist/UI/Components/SideOver/SideOver.js +5 -5
  268. package/build/dist/UI/Components/StatusPage/ResourceGrid.js +28 -2
  269. package/build/dist/UI/Components/StatusPage/ResourceGrid.js.map +1 -1
  270. package/build/dist/UI/Components/StatusPage/ResourceList.js +87 -20
  271. package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -1
  272. package/build/dist/UI/Components/Toggle/Toggle.js +9 -1
  273. package/build/dist/UI/Components/Toggle/Toggle.js.map +1 -1
  274. package/build/dist/UI/Components/Types/FieldType.js +1 -0
  275. package/build/dist/UI/Components/Types/FieldType.js.map +1 -1
  276. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +37 -1
  277. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -1
  278. package/build/dist/Utils/NetworkDiscovery/ScanNameUtil.js +120 -0
  279. package/build/dist/Utils/NetworkDiscovery/ScanNameUtil.js.map +1 -0
  280. package/build/dist/Utils/StatusPage/ResourceExplorer.js +171 -0
  281. package/build/dist/Utils/StatusPage/ResourceExplorer.js.map +1 -1
  282. package/build/dist/Utils/Telemetry/LlmMetricQuery.js +119 -0
  283. package/build/dist/Utils/Telemetry/LlmMetricQuery.js.map +1 -0
  284. package/jest.config.json +2 -0
  285. package/package.json +1 -1
  286. package/tsconfig.json +2 -1
  287. package/Tests/UI/Components/Toast.test.tsx +0 -197
  288. package/Tests/UI/Components/ToastStacking.test.tsx +0 -198
  289. package/UI/Components/Toast/Toast.tsx +0 -189
  290. package/UI/Components/Toast/ToastInit.tsx +0 -110
  291. package/build/dist/UI/Components/Toast/Toast.js +0 -123
  292. package/build/dist/UI/Components/Toast/Toast.js.map +0 -1
  293. package/build/dist/UI/Components/Toast/ToastInit.js +0 -53
  294. package/build/dist/UI/Components/Toast/ToastInit.js.map +0 -1
@@ -15,7 +15,7 @@ import {
15
15
  waitFor,
16
16
  within,
17
17
  } from "@testing-library/react";
18
- import React from "react";
18
+ import React, { act } from "react";
19
19
  import {
20
20
  MemoryRouter,
21
21
  Outlet,
@@ -361,6 +361,40 @@ const respondWithMethods: RespondWithMethodsFunction = (
361
361
  });
362
362
  };
363
363
 
364
+ type RespondWithDeletionImpactFunction = (impact: JSONObject) => void;
365
+
366
+ /*
367
+ * The impact endpoint hangs off the same base route as the list, so its url
368
+ * CONTAINS the list's url. It is matched first for that reason: the other way
369
+ * round, every impact read is answered with the method list and the
370
+ * confirmation draws a preview out of a payload that has no counts in it.
371
+ */
372
+ const respondWithDeletionImpact: RespondWithDeletionImpactFunction = (
373
+ impact: JSONObject,
374
+ ): void => {
375
+ apiGetMock.mockImplementation((data: any) => {
376
+ const url: string = String(data.url);
377
+
378
+ if (url.includes("/deletion-impact")) {
379
+ return Promise.resolve(new HTTPResponse<JSONObject>(200, impact, {}));
380
+ }
381
+
382
+ if (url.includes("/user-notification-method-admin/")) {
383
+ return Promise.resolve(
384
+ new HTTPResponse<JSONObject>(
385
+ 200,
386
+ { methods: [VERIFIED_EMAIL, UNVERIFIED_SMS, WEBHOOK] },
387
+ {},
388
+ ),
389
+ );
390
+ }
391
+
392
+ return Promise.resolve(
393
+ new HTTPResponse<JSONObject>(200, READINESS_PAYLOAD, {}),
394
+ );
395
+ });
396
+ };
397
+
364
398
  type BuildTeamMemberFunction = () => TeamMember;
365
399
 
366
400
  const buildTeamMember: BuildTeamMemberFunction = (): TeamMember => {
@@ -375,6 +409,96 @@ const buildTeamMember: BuildTeamMemberFunction = (): TeamMember => {
375
409
  return member;
376
410
  };
377
411
 
412
+ type FlushEffectsFunction = () => Promise<void>;
413
+
414
+ /*
415
+ * One turn of the macrotask queue, inside `act`.
416
+ *
417
+ * That is what it takes for an effect scheduled by the commit that just
418
+ * happened to run, and for the render THAT effect schedules to commit in turn.
419
+ * Awaiting a promise is not enough: React 18 schedules its work through the
420
+ * scheduler rather than through the microtask queue, so a state update made
421
+ * from an effect is still uncommitted when the next microtask runs.
422
+ */
423
+ const flushEffects: FlushEffectsFunction = async (): Promise<void> => {
424
+ await act(async (): Promise<void> => {
425
+ await new Promise<void>((resolve: () => void): void => {
426
+ setTimeout(resolve, 0);
427
+ });
428
+ });
429
+ };
430
+
431
+ type IsSettledFunction = () => boolean;
432
+
433
+ /*
434
+ * Three things, and none of them is sufficient alone.
435
+ *
436
+ * `pendingRequestCount` dips to zero between the SECTION layout's reads
437
+ * finishing and the page's own read being issued — the page does not exist
438
+ * until the layout has an identity to render it with, so waiting on the counter
439
+ * alone hands the test a loading skeleton. Worse, the counter goes back to zero
440
+ * one turn BEFORE the awaiting component sets any state: the tracking `finally`
441
+ * is registered on the response promise ahead of the component's own
442
+ * continuation, so a poll landing in that turn sees "nothing in flight" while
443
+ * the section is still drawing its PageLoader.
444
+ *
445
+ * The page's own skeleton is no better on its own: it is absent before the
446
+ * first render, and the SELF branch never renders one at all.
447
+ *
448
+ * So the loaders are named explicitly — `bar-loader` is PageLoader's, and it is
449
+ * the section still deciding who this page is about.
450
+ */
451
+ const isSettled: IsSettledFunction = (): boolean => {
452
+ return (
453
+ pendingRequestCount === 0 &&
454
+ screen.queryByTestId("methods-loading") === null &&
455
+ screen.queryByTestId("bar-loader") === null
456
+ );
457
+ };
458
+
459
+ type AssertSettledFunction = () => void;
460
+
461
+ /*
462
+ * The same three conditions as assertions, so a wait that runs out says WHICH
463
+ * of them never came true rather than "timed out".
464
+ */
465
+ const assertSettled: AssertSettledFunction = (): void => {
466
+ expect(pendingRequestCount).toBe(0);
467
+ expect(screen.queryByTestId("methods-loading")).not.toBeInTheDocument();
468
+ expect(screen.queryByTestId("bar-loader")).not.toBeInTheDocument();
469
+ };
470
+
471
+ type WaitForSettledFunction = () => Promise<void>;
472
+
473
+ /*
474
+ * Settled, and STILL settled once everything that commit scheduled has run.
475
+ *
476
+ * The recheck is the part that matters, and the self view is why. It issues no
477
+ * request of its own, so the moment the layout's reads finish, every condition
478
+ * above holds — while the tab strip it just rendered has an empty panel, because
479
+ * Tabs picks its opening tab in an effect. A single `waitFor` returns on that
480
+ * frame and the test reads a page that has not finished appearing. Flushing and
481
+ * asking again is what turns "no longer busy" into "done".
482
+ *
483
+ * Looping rather than flushing once, because a flush can also START work — the
484
+ * commit it releases is the one that mounts the page that issues the read.
485
+ */
486
+ const waitForSettled: WaitForSettledFunction = async (): Promise<void> => {
487
+ for (let attempt: number = 0; attempt < 25; attempt++) {
488
+ await waitFor(assertSettled, { timeout: 4000 });
489
+
490
+ await flushEffects();
491
+
492
+ if (isSettled()) {
493
+ return;
494
+ }
495
+ }
496
+
497
+ throw new Error(
498
+ "the page never stopped loading: it kept scheduling more work after every flush",
499
+ );
500
+ };
501
+
378
502
  type RenderPageFunction = () => Promise<HTMLElement>;
379
503
 
380
504
  const renderPage: RenderPageFunction = async (): Promise<HTMLElement> => {
@@ -400,26 +524,75 @@ const renderPage: RenderPageFunction = async (): Promise<HTMLElement> => {
400
524
  </MemoryRouter>,
401
525
  );
402
526
 
403
- /*
404
- * Two conditions, and neither is sufficient alone.
405
- *
406
- * `pendingRequestCount` dips to zero between the SECTION layout's reads
407
- * finishing and the page's own read being issued — the page does not exist
408
- * until the layout has an identity to render it with, so waiting on the
409
- * counter alone hands the test a loading skeleton. The skeleton's absence
410
- * alone is no better: it is also absent before the first render.
411
- */
412
- await waitFor(
413
- (): void => {
414
- expect(pendingRequestCount).toBe(0);
415
- expect(screen.queryByTestId("methods-loading")).not.toBeInTheDocument();
416
- },
417
- { timeout: 4000 },
418
- );
527
+ await waitForSettled();
419
528
 
420
529
  return container;
421
530
  };
422
531
 
532
+ type RenderSelfPageFunction = () => Promise<HTMLElement>;
533
+
534
+ /*
535
+ * The same route, read by the person it is about. Everything else — the
536
+ * permissions, the fixtures, the project — stays exactly as the admin case
537
+ * leaves it, so what these assertions read is the isSelf branch and not some
538
+ * other difference smuggled in beside it.
539
+ */
540
+ const renderSelfPage: RenderSelfPageFunction =
541
+ async (): Promise<HTMLElement> => {
542
+ jest
543
+ .spyOn(UserUtil, "getUserId")
544
+ .mockReturnValue(new ObjectID(TARGET_USER_ID_STRING));
545
+
546
+ return renderPage();
547
+ };
548
+
549
+ type OpenTabFunction = (name: string) => Promise<void>;
550
+
551
+ const openTab: OpenTabFunction = async (name: string): Promise<void> => {
552
+ fireEvent.click(screen.getByTestId(`tab-${name}`));
553
+
554
+ await waitForSettled();
555
+ };
556
+
557
+ type ChannelOptionsFunction = () => Array<string>;
558
+
559
+ /*
560
+ * The channel dropdown is a react-select, not a <select>.
561
+ *
562
+ * It renders no <option> elements at all until its menu is opened, so a
563
+ * querySelectorAll("option") over the closed one answers "no options" for a
564
+ * dropdown with four — and every "that channel is not offered" assertion
565
+ * written against that answer passes without having looked at anything.
566
+ */
567
+ const channelOptions: ChannelOptionsFunction = (): Array<string> => {
568
+ const combobox: HTMLElement | null =
569
+ document.querySelector<HTMLElement>("[role=combobox]");
570
+
571
+ if (!combobox) {
572
+ throw new Error("the add form has no channel dropdown");
573
+ }
574
+
575
+ fireEvent.keyDown(combobox, { key: "ArrowDown", code: 40, keyCode: 40 });
576
+
577
+ return screen.queryAllByRole("option").map((option: HTMLElement): string => {
578
+ return option.textContent || "";
579
+ });
580
+ };
581
+
582
+ type ChooseChannelFunction = (label: string) => void;
583
+
584
+ const chooseChannel: ChooseChannelFunction = (label: string): void => {
585
+ const offered: Array<string> = channelOptions();
586
+
587
+ if (!offered.includes(label)) {
588
+ throw new Error(
589
+ `the add form does not offer ${label}; it offers ${offered.join(", ")}`,
590
+ );
591
+ }
592
+
593
+ fireEvent.click(screen.getByRole("option", { name: label }));
594
+ };
595
+
423
596
  type MethodRowsFunction = () => Array<HTMLElement>;
424
597
 
425
598
  const methodRows: MethodRowsFunction = (): Array<HTMLElement> => {
@@ -667,17 +840,14 @@ describe("adding a method on somebody's behalf", () => {
667
840
  * at all, so an admin-created one would be live the instant it was
668
841
  * written, which is exactly the silent redirect this design rules out.
669
842
  */
670
- const modalText: string = document.body.textContent || "";
671
-
672
- expect(modalText).toContain("Email");
673
- expect(modalText).toContain("SMS");
674
- expect(modalText).toContain("WhatsApp");
843
+ /*
844
+ * Read out of the OPEN menu. The page copy under the list mentions WhatsApp
845
+ * and the rows above it mention SMS, so a scan of document.body says
846
+ * "offered" about a channel this form has never heard of.
847
+ */
848
+ const optionLabels: Array<string> = channelOptions();
675
849
 
676
- const optionLabels: Array<string> = Array.from(
677
- document.querySelectorAll("option"),
678
- ).map((option: Element): string => {
679
- return option.textContent || "";
680
- });
850
+ expect(optionLabels).toEqual(["Email", "SMS", "Phone call", "WhatsApp"]);
681
851
 
682
852
  for (const forbidden of [
683
853
  "Push",
@@ -745,11 +915,7 @@ describe("adding a method on somebody's behalf", () => {
745
915
 
746
916
  fireEvent.change(valueInput, { target: { value: RAW_PHONE } });
747
917
 
748
- const methodSelect: HTMLElement | null = document.querySelector("select");
749
-
750
- if (methodSelect) {
751
- fireEvent.change(methodSelect, { target: { value: "SMS" } });
752
- }
918
+ chooseChannel("SMS");
753
919
 
754
920
  fireEvent.click(within(modal()).getByText("Add"));
755
921
 
@@ -769,6 +935,14 @@ describe("adding a method on somebody's behalf", () => {
769
935
  `/user-notification-method-admin/user/${TARGET_USER_ID_STRING}`,
770
936
  );
771
937
  expect(call.data.value).toBe(RAW_PHONE);
938
+
939
+ /*
940
+ * And the channel the admin picked, not the one the form opened on. The
941
+ * previous spelling of this reached for a <select> that a react-select
942
+ * dropdown never renders, found nothing, and asserted the value of a form
943
+ * still sitting on its default.
944
+ */
945
+ expect(call.data.methodType).toBe("SMS");
772
946
  });
773
947
 
774
948
  test("re-reads the list and the readiness summary after a successful add", async () => {
@@ -963,9 +1137,7 @@ describe("removing a method", () => {
963
1137
 
964
1138
  clickButton(rowFor(MASKED_EMAIL), "Remove");
965
1139
 
966
- await waitFor((): void => {
967
- expect(pendingRequestCount).toBe(0);
968
- });
1140
+ await waitForSettled();
969
1141
 
970
1142
  /*
971
1143
  * A preview is an improvement on the confirmation, not a precondition for
@@ -1147,9 +1319,7 @@ describe("no unmasked identifier reaches the DOM", () => {
1147
1319
 
1148
1320
  clickButton(rowFor(MASKED_EMAIL), "Remove");
1149
1321
 
1150
- await waitFor((): void => {
1151
- expect(pendingRequestCount).toBe(0);
1152
- });
1322
+ await waitForSettled();
1153
1323
 
1154
1324
  expect(document.body.textContent).toContain(MASKED_EMAIL);
1155
1325
 
@@ -1160,3 +1330,571 @@ describe("no unmasked identifier reaches the DOM", () => {
1160
1330
  }
1161
1331
  });
1162
1332
  });
1333
+
1334
+ /*
1335
+ * The self view, in the depth the one test above it does not go to.
1336
+ *
1337
+ * This branch is the reason the masked admin list is allowed to be as narrow as
1338
+ * it is: everything an administrator cannot do here — read an identifier, enter
1339
+ * a verification code, set up a push device — the owner can do on the very same
1340
+ * route, because for them the method models are their own rows. A self view
1341
+ * that quietly rendered nothing would look exactly like a self view that
1342
+ * worked, right up until somebody needed to add a phone number.
1343
+ */
1344
+ describe("the self-serve view", () => {
1345
+ test("reaches every one of the nine method models across its tabs", async () => {
1346
+ await renderSelfPage();
1347
+
1348
+ /*
1349
+ * Direct Contact is the opening tab, and the five channels a person is
1350
+ * paged on directly live in it.
1351
+ */
1352
+ expect(mountedTableModels).toContain(UserEmail);
1353
+ expect(mountedTableModels).toContain(UserSMS);
1354
+ expect(mountedTableModels).toContain(UserCall);
1355
+ expect(mountedTableModels).toContain(UserWhatsApp);
1356
+ expect(mountedTableModels).toContain(UserTelegram);
1357
+
1358
+ await openTab("Workspace Apps");
1359
+
1360
+ expect(mountedTableModels).toContain(UserSlack);
1361
+ expect(mountedTableModels).toContain(UserMicrosoftTeams);
1362
+
1363
+ await openTab("Push Notifications");
1364
+
1365
+ expect(mountedTableModels).toContain(UserPush);
1366
+
1367
+ await openTab("Webhooks");
1368
+
1369
+ expect(mountedTableModels).toContain(UserWebhook);
1370
+
1371
+ /*
1372
+ * Asserted as a set at the end as well, because the four channels an
1373
+ * administrator cannot add are exactly the ones parked on the later tabs —
1374
+ * a tab that stopped rendering would take the only surface those channels
1375
+ * have with it.
1376
+ */
1377
+ for (const model of NOTIFICATION_METHOD_MODELS) {
1378
+ expect(mountedTableModels).toContain(model);
1379
+ }
1380
+ });
1381
+
1382
+ test("draws none of the administrative surface", async () => {
1383
+ const container: HTMLElement = await renderSelfPage();
1384
+
1385
+ expect(
1386
+ screen.queryByTestId("admin-notification-method-list"),
1387
+ ).not.toBeInTheDocument();
1388
+ expect(
1389
+ screen.queryByTestId("no-methods-empty-state"),
1390
+ ).not.toBeInTheDocument();
1391
+ expect(screen.queryByText("Add notification method")).toBeNull();
1392
+
1393
+ /*
1394
+ * And none of its copy. Masked identifiers and "waiting for them to verify"
1395
+ * are both written for somebody who is not the owner; addressed to the
1396
+ * owner they describe a restriction that is not real, on a page where they
1397
+ * can do the verifying themselves.
1398
+ */
1399
+ expect(container.textContent).not.toContain(MASKED_EMAIL);
1400
+ expect(container.textContent).not.toContain(
1401
+ `Waiting for ${TARGET_USER_FIRST_NAME} to verify`,
1402
+ );
1403
+ });
1404
+
1405
+ test("asks the admin capability for nothing at all", async () => {
1406
+ await renderSelfPage();
1407
+
1408
+ /*
1409
+ * Not only the list read. The endpoint also carries the add, the removal,
1410
+ * the impact preview and the resend, and none of them is this page's to
1411
+ * call for its own owner — the ordinary settings components do all of it
1412
+ * against the models directly.
1413
+ */
1414
+ const adminCalls: Array<unknown> = [
1415
+ ...apiGetMock.mock.calls,
1416
+ ...apiPostMock.mock.calls,
1417
+ ...apiDeleteMock.mock.calls,
1418
+ ].filter((call: any): boolean => {
1419
+ return String(call[0].url).includes("/user-notification-method-admin/");
1420
+ });
1421
+
1422
+ expect(adminCalls).toHaveLength(0);
1423
+ });
1424
+
1425
+ test("says these are the same settings they already have", async () => {
1426
+ const container: HTMLElement = await renderSelfPage();
1427
+
1428
+ /*
1429
+ * The banner the section renders for an owner. Somebody who arrives here
1430
+ * from a colleague's row has to be able to tell at a glance that the page
1431
+ * has changed hands — and the answer to "why does this look different from
1432
+ * User Settings" is that it is not different.
1433
+ */
1434
+ expect(container.textContent).toContain(
1435
+ "This is your own on-call configuration",
1436
+ );
1437
+ });
1438
+ });
1439
+
1440
+ /*
1441
+ * What the page does with a row it cannot make sense of.
1442
+ *
1443
+ * Every one of these is a shape the server does not send today. They are here
1444
+ * because parseMethod exists — a page that parses field by field has decided
1445
+ * that a surprising payload is a thing that happens, and the value of that
1446
+ * decision is entirely in what it does next.
1447
+ */
1448
+ describe("a row the server sends malformed", () => {
1449
+ test("a row with no id or no channel is dropped, not drawn blank", async () => {
1450
+ respondWithMethods([
1451
+ VERIFIED_EMAIL,
1452
+ // No id: there is nothing to remove or resend against.
1453
+ { methodType: "SMS", maskedIdentifier: MASKED_PHONE } as JSONObject,
1454
+ // No channel: every url this page builds puts the channel in the path.
1455
+ { methodId: SMS_METHOD_ID, maskedIdentifier: MASKED_PHONE } as JSONObject,
1456
+ // Neither.
1457
+ { maskedIdentifier: MASKED_WEBHOOK } as JSONObject,
1458
+ ]);
1459
+
1460
+ await renderPage();
1461
+
1462
+ expect(methodRows()).toHaveLength(1);
1463
+ expect(rowFor(MASKED_EMAIL).textContent).toContain("Email");
1464
+ });
1465
+
1466
+ test("a channel with no masked identifier is blank, never the word undefined", async () => {
1467
+ respondWithMethods([
1468
+ {
1469
+ methodId: EMAIL_METHOD_ID,
1470
+ methodType: "Email",
1471
+ isVerified: true,
1472
+ isAdminAddable: true,
1473
+ } as JSONObject,
1474
+ ]);
1475
+
1476
+ const container: HTMLElement = await renderPage();
1477
+
1478
+ const rows: Array<HTMLElement> = methodRows();
1479
+
1480
+ expect(rows).toHaveLength(1);
1481
+ expect(rows[0]!.textContent).toContain("Email");
1482
+
1483
+ /*
1484
+ * The row is still worth drawing — the channel and the controls are the
1485
+ * useful part — but "undefined" beside a channel name reads as an
1486
+ * identifier, and it is the one word on this page nobody can act on.
1487
+ */
1488
+ expect(container.textContent).not.toContain("undefined");
1489
+ });
1490
+
1491
+ test("an absent verification flag reads as unverified", async () => {
1492
+ respondWithMethods([
1493
+ {
1494
+ methodId: SMS_METHOD_ID,
1495
+ methodType: "SMS",
1496
+ maskedIdentifier: MASKED_PHONE,
1497
+ } as JSONObject,
1498
+ ]);
1499
+
1500
+ await renderPage();
1501
+
1502
+ /*
1503
+ * The safe direction, and not an arbitrary one: an unverified row is drawn
1504
+ * as still waiting on its owner, which is a true statement about a method
1505
+ * whose state is unknown. The other default would print "Verified" over a
1506
+ * channel nothing has confirmed.
1507
+ */
1508
+ expect(rowFor(MASKED_PHONE).textContent).toContain(
1509
+ `Waiting for ${TARGET_USER_FIRST_NAME} to verify`,
1510
+ );
1511
+ });
1512
+
1513
+ test("an unverified channel an admin could not have added offers no resend", async () => {
1514
+ const UNVERIFIED_TELEGRAM: JSONObject = methodJson({
1515
+ methodId: WEBHOOK_METHOD_ID,
1516
+ methodType: "Telegram",
1517
+ maskedIdentifier: "@ja•••",
1518
+ isVerified: false,
1519
+ isAdminAddable: false,
1520
+ leakedRawValue: RAW_PHONE,
1521
+ });
1522
+
1523
+ respondWithMethods([UNVERIFIED_TELEGRAM]);
1524
+
1525
+ await renderPage();
1526
+
1527
+ const row: HTMLElement = rowFor("@ja•••");
1528
+
1529
+ /*
1530
+ * Two separate conditions, and this row is the one that tells them apart:
1531
+ * unverified is not sufficient. Telegram's code is not something this page
1532
+ * can cause to be sent — the account holder has to message the bot — so a
1533
+ * "Resend code" button here is a button that can only fail.
1534
+ */
1535
+ expect(within(row).queryByText("Resend code")).toBeNull();
1536
+ expect(within(row).getByText("Remove")).toBeInTheDocument();
1537
+ });
1538
+ });
1539
+
1540
+ /*
1541
+ * The numbers in the removal confirmation, which are the last thing an
1542
+ * administrator reads before a cascade.
1543
+ */
1544
+ describe("the deletion preview's arithmetic", () => {
1545
+ test("counts of one are not pluralised", async () => {
1546
+ await renderPage();
1547
+
1548
+ respondWithDeletionImpact({
1549
+ rulesDeletedCount: 1,
1550
+ coverageLostCount: 1,
1551
+ verifiedMethodCountAfterDeletion: 1,
1552
+ reachability: "PartiallyReady",
1553
+ isFallbackEnabled: true,
1554
+ isTruncated: false,
1555
+ });
1556
+
1557
+ clickButton(rowFor(MASKED_EMAIL), "Remove");
1558
+
1559
+ const preview: HTMLElement = await screen.findByTestId("deletion-preview");
1560
+
1561
+ expect(preview.textContent).toContain(
1562
+ "1 notification rule will be deleted",
1563
+ );
1564
+ expect(preview.textContent).not.toContain("rules will be deleted");
1565
+ expect(preview.textContent).toContain("1 severity");
1566
+ expect(preview.textContent).not.toContain("severities");
1567
+ });
1568
+
1569
+ test("a removal that costs no coverage does not invent a loss", async () => {
1570
+ await renderPage();
1571
+
1572
+ respondWithDeletionImpact({
1573
+ rulesDeletedCount: 2,
1574
+ coverageLostCount: 0,
1575
+ verifiedMethodCountAfterDeletion: 1,
1576
+ reachability: "Ready",
1577
+ isFallbackEnabled: true,
1578
+ isTruncated: false,
1579
+ });
1580
+
1581
+ clickButton(rowFor(MASKED_EMAIL), "Remove");
1582
+
1583
+ const preview: HTMLElement = await screen.findByTestId("deletion-preview");
1584
+
1585
+ /*
1586
+ * Two rules go, and every severity still has one. Saying "leaving 0
1587
+ * severities with no rule at all" would be true and would still read as a
1588
+ * warning, which is how a confirmation earns its dismissal.
1589
+ */
1590
+ expect(preview.textContent).toContain(
1591
+ "2 notification rules will be deleted",
1592
+ );
1593
+ expect(preview.textContent).not.toContain("leaving");
1594
+ });
1595
+
1596
+ test("somebody who keeps a verified method is not called unreachable", async () => {
1597
+ await renderPage();
1598
+
1599
+ respondWithDeletionImpact({
1600
+ rulesDeletedCount: 1,
1601
+ coverageLostCount: 0,
1602
+ verifiedMethodCountAfterDeletion: 2,
1603
+ reachability: "Ready",
1604
+ isFallbackEnabled: true,
1605
+ isTruncated: false,
1606
+ });
1607
+
1608
+ clickButton(rowFor(MASKED_EMAIL), "Remove");
1609
+
1610
+ await screen.findByTestId("deletion-preview");
1611
+
1612
+ /*
1613
+ * The one sentence in this modal that is worth interrupting for only works
1614
+ * while it is rare. Printed over a removal that leaves two working channels
1615
+ * behind, it is the sentence somebody learns to scroll past.
1616
+ */
1617
+ expect(document.body.textContent).not.toContain(
1618
+ "will have no verified notification method left",
1619
+ );
1620
+ });
1621
+
1622
+ test("a truncated count admits it is a lower bound", async () => {
1623
+ await renderPage();
1624
+
1625
+ respondWithDeletionImpact({
1626
+ rulesDeletedCount: 500,
1627
+ coverageLostCount: 3,
1628
+ verifiedMethodCountAfterDeletion: 1,
1629
+ reachability: "PartiallyReady",
1630
+ isFallbackEnabled: true,
1631
+ isTruncated: true,
1632
+ });
1633
+
1634
+ clickButton(rowFor(MASKED_EMAIL), "Remove");
1635
+
1636
+ const preview: HTMLElement = await screen.findByTestId("deletion-preview");
1637
+
1638
+ /*
1639
+ * "500 rules will be deleted" is a number an administrator will weigh the
1640
+ * decision against, and a capped read that does not say it was capped hands
1641
+ * them a floor dressed as a total.
1642
+ */
1643
+ expect(preview.textContent).toContain("lower bound");
1644
+ });
1645
+
1646
+ test("a payload that is not an impact is not drawn as one", async () => {
1647
+ await renderPage();
1648
+
1649
+ // A 200 whose body has none of the counts in it.
1650
+ respondWithDeletionImpact({ methods: [] });
1651
+
1652
+ clickButton(rowFor(MASKED_EMAIL), "Remove");
1653
+
1654
+ await waitForSettled();
1655
+
1656
+ /*
1657
+ * Cast rather than parsed, this renders "undefined notification rules will
1658
+ * be deleted" — and, worse, silently answers the reachability question,
1659
+ * because `undefined === 0` is false and the sentence about being left with
1660
+ * no verified method simply does not appear. The confirmation falls back to
1661
+ * the general warning instead, which is true without any numbers at all.
1662
+ */
1663
+ expect(screen.queryByTestId("deletion-preview")).not.toBeInTheDocument();
1664
+ expect(document.body.textContent).not.toContain("undefined");
1665
+ expect(document.body.textContent).toContain(
1666
+ "every notification rule that points at it goes with it",
1667
+ );
1668
+
1669
+ // And it is still a removal the admin can go through with.
1670
+ fireEvent.click(within(modal()).getByText("Remove"));
1671
+
1672
+ await waitFor((): void => {
1673
+ expect(apiDeleteMock).toHaveBeenCalled();
1674
+ });
1675
+ });
1676
+ });
1677
+
1678
+ describe("a write the server refuses", () => {
1679
+ test("a refused removal keeps the row and says why", async () => {
1680
+ await renderPage();
1681
+
1682
+ apiDeleteMock.mockResolvedValue(
1683
+ new HTTPErrorResponse(
1684
+ 400,
1685
+ { message: "This method has already been removed" },
1686
+ {},
1687
+ ) as never,
1688
+ );
1689
+
1690
+ clickButton(rowFor(MASKED_PHONE), "Remove");
1691
+
1692
+ fireEvent.click(within(modal()).getByText("Remove"));
1693
+
1694
+ await waitFor((): void => {
1695
+ expect(apiDeleteMock).toHaveBeenCalled();
1696
+ });
1697
+
1698
+ const messages: Array<HTMLElement> =
1699
+ await screen.findAllByText(/already been removed/);
1700
+
1701
+ expect(messages.length).toBeGreaterThan(0);
1702
+
1703
+ /*
1704
+ * And the list underneath is untouched. Removing the row optimistically and
1705
+ * then failing would leave an administrator looking at a list that says the
1706
+ * method is gone, over an account where it is still live.
1707
+ */
1708
+ expect(methodRows()).toHaveLength(3);
1709
+ expect(rowFor(MASKED_PHONE)).toBeInTheDocument();
1710
+ });
1711
+
1712
+ test("a successful removal re-reads the list and the readiness summary", async () => {
1713
+ await renderPage();
1714
+
1715
+ apiGetMock.mockClear();
1716
+
1717
+ clickButton(rowFor(MASKED_PHONE), "Remove");
1718
+
1719
+ fireEvent.click(within(modal()).getByText("Remove"));
1720
+
1721
+ await waitFor((): void => {
1722
+ expect(apiDeleteMock).toHaveBeenCalled();
1723
+ });
1724
+
1725
+ await waitFor((): void => {
1726
+ const urls: Array<string> = apiGetMock.mock.calls.map(
1727
+ (call: Array<any>): string => {
1728
+ return String(call[0].url);
1729
+ },
1730
+ );
1731
+
1732
+ expect(
1733
+ urls.some((url: string): boolean => {
1734
+ return (
1735
+ url.includes("/user-notification-method-admin/user/") &&
1736
+ !url.includes("/deletion-impact")
1737
+ );
1738
+ }),
1739
+ ).toBe(true);
1740
+
1741
+ /*
1742
+ * And readiness with `refresh`, for the same reason the add does it: a
1743
+ * removal is the change most likely to have made somebody unreachable,
1744
+ * and a summary answered out of the service's 60s cache would show the
1745
+ * state from before it.
1746
+ */
1747
+ expect(
1748
+ urls.some((url: string): boolean => {
1749
+ return url.includes("/on-call-readiness/") && url.includes("refresh");
1750
+ }),
1751
+ ).toBe(true);
1752
+ });
1753
+ });
1754
+
1755
+ test("a refused resend does not claim the code was sent", async () => {
1756
+ await renderPage();
1757
+
1758
+ apiPostMock.mockResolvedValue(
1759
+ new HTTPErrorResponse(
1760
+ 500,
1761
+ { message: "the SMS provider is down" },
1762
+ {},
1763
+ ) as never,
1764
+ );
1765
+
1766
+ clickButton(rowFor(MASKED_PHONE), "Resend code");
1767
+
1768
+ fireEvent.click(within(modal()).getByText("Resend code"));
1769
+
1770
+ await waitFor((): void => {
1771
+ expect(apiPostMock).toHaveBeenCalled();
1772
+ });
1773
+
1774
+ await waitForSettled();
1775
+
1776
+ /*
1777
+ * "Code sent" over a code that was not sent is the worst of the three
1778
+ * outcomes here: the admin stops, tells the responder to go and look, and
1779
+ * the responder finds nothing.
1780
+ */
1781
+ expect(document.body.textContent).toContain("the SMS provider is down");
1782
+ expect(document.body.textContent).not.toContain("Code sent");
1783
+ });
1784
+
1785
+ test("a sent code says whose job the rest of it is", async () => {
1786
+ await renderPage();
1787
+
1788
+ clickButton(rowFor(MASKED_PHONE), "Resend code");
1789
+
1790
+ fireEvent.click(within(modal()).getByText("Resend code"));
1791
+
1792
+ await waitFor((): void => {
1793
+ expect(apiPostMock).toHaveBeenCalled();
1794
+ });
1795
+
1796
+ await waitForSettled();
1797
+
1798
+ expect(document.body.textContent).toContain("Code sent");
1799
+ expect(document.body.textContent).toContain(
1800
+ "needs to enter it in their own user settings",
1801
+ );
1802
+ });
1803
+
1804
+ test("the resend confirmation names the mask, not the number", async () => {
1805
+ await renderPage();
1806
+
1807
+ clickButton(rowFor(MASKED_PHONE), "Resend code");
1808
+
1809
+ expect(document.body.textContent).toContain(MASKED_PHONE);
1810
+
1811
+ for (const raw of ALL_RAW_IDENTIFIERS) {
1812
+ expect(document.body.innerHTML).not.toContain(raw);
1813
+ }
1814
+ });
1815
+
1816
+ test("closing the add form writes nothing", async () => {
1817
+ await renderPage();
1818
+
1819
+ clickButton(document.body, "Add notification method");
1820
+
1821
+ fireEvent.change(
1822
+ screen.getByPlaceholderText("you@company.com or +15551234567"),
1823
+ { target: { value: RAW_PHONE } },
1824
+ );
1825
+
1826
+ fireEvent.click(within(modal()).getByText("Cancel"));
1827
+
1828
+ await waitForSettled();
1829
+
1830
+ expect(apiPostMock).not.toHaveBeenCalled();
1831
+ expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
1832
+ });
1833
+
1834
+ test("the form opens on the one channel no project setting can disable", async () => {
1835
+ await renderPage();
1836
+
1837
+ clickButton(document.body, "Add notification method");
1838
+
1839
+ fireEvent.change(
1840
+ screen.getByPlaceholderText("you@company.com or +15551234567"),
1841
+ { target: { value: RAW_EMAIL } },
1842
+ );
1843
+
1844
+ fireEvent.click(within(modal()).getByText("Add"));
1845
+
1846
+ await waitFor((): void => {
1847
+ expect(apiPostMock).toHaveBeenCalled();
1848
+ });
1849
+
1850
+ /*
1851
+ * Submitted without touching the dropdown, which is the whole point of
1852
+ * preselecting it: SMS, Call and WhatsApp each have a per-project switch
1853
+ * behind them, and a default that lands on a disabled channel turns the
1854
+ * commonest action on this page into a refusal.
1855
+ */
1856
+ expect(apiPostMock.mock.calls[0]![0].data.methodType).toBe("Email");
1857
+ });
1858
+ });
1859
+
1860
+ describe("who gets the controls", () => {
1861
+ test("a master admin keeps them with no project permission at all", async () => {
1862
+ jest.spyOn(UserUtil, "isMasterAdmin").mockReturnValue(true);
1863
+ jest.spyOn(PermissionUtil, "getAllPermissions").mockReturnValue([]);
1864
+
1865
+ await renderPage();
1866
+
1867
+ /*
1868
+ * A master admin holds no project permissions by construction — they are
1869
+ * not a member of the project — so a page checking only the project triple
1870
+ * hides this section from the one account that exists to repair things
1871
+ * nobody else can.
1872
+ */
1873
+ expect(screen.getByText("Add notification method")).toBeInTheDocument();
1874
+ expect(
1875
+ within(rowFor(MASKED_EMAIL)).getByText("Remove"),
1876
+ ).toBeInTheDocument();
1877
+ });
1878
+
1879
+ test("a reader looking at an empty account is told how to get the permission", async () => {
1880
+ jest
1881
+ .spyOn(PermissionUtil, "getAllPermissions")
1882
+ .mockReturnValue([Permission.ReadProjectUserNotificationRule]);
1883
+
1884
+ respondWithMethods([]);
1885
+
1886
+ const container: HTMLElement = await renderPage();
1887
+
1888
+ expect(screen.getByTestId("no-methods-empty-state")).toBeInTheDocument();
1889
+
1890
+ /*
1891
+ * The same alarming fact, with the action that is actually open to THIS
1892
+ * reader. Offering them "Add one for Jane" would point at a button they
1893
+ * cannot see, on a call the API would refuse.
1894
+ */
1895
+ expect(container.textContent).toContain("Manage User Notification Methods");
1896
+ expect(container.textContent).not.toContain(
1897
+ `Add one for ${TARGET_USER_FIRST_NAME}`,
1898
+ );
1899
+ });
1900
+ });