@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
@@ -0,0 +1,4672 @@
1
+ /** @timezone Asia/Tokyo */
2
+ import {
3
+ afterAll,
4
+ afterEach,
5
+ beforeAll,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ jest,
10
+ test,
11
+ } from "@jest/globals";
12
+ import type { SpyInstance } from "jest-mock";
13
+ import http from "http";
14
+ import type { AddressInfo } from "net";
15
+ import util from "util";
16
+ import moment from "moment-timezone";
17
+
18
+ /*
19
+ * Issue #4111, end to end: in Microsoft Teams, "create incident" and "create
20
+ * maintenance" answered "Sorry, I encountered an error processing your
21
+ * request. Please try again later." - and every such bubble showed up TWICE -
22
+ * while "show scheduled maintenance" worked.
23
+ *
24
+ * What master (14.0.8) did, which this file was first written to reproduce:
25
+ * the form listed every monitor, label and on-call policy of the project, so
26
+ * with a few hundred monitors the card was over Teams' size limit. The Bot
27
+ * Connector answered 413 MessageSizeTooBig -> the handler replied "Sorry..."
28
+ * and rethrew -> the CloudAdapter (it has no onTurnError) answered Teams HTTP
29
+ * 500 -> Teams delivered the same activity again -> a second "Sorry..."
30
+ * bubble. Any failure doubled that way, not only the 413.
31
+ *
32
+ * What these tests pin, the fixed behaviour: every inbound turn is answered
33
+ * HTTP 200 (an invoke with its invoke response) and a message gets exactly
34
+ * one reply; the form is fitted to a size budget and names what it left off;
35
+ * a card Teams refuses as too large is sent again smaller and then replaced
36
+ * by a text that says why, while any other refusal is explained at once; a
37
+ * redelivered activity id is dropped, even while its first delivery is still
38
+ * being handled; a submitted form is confirmed before the form is removed,
39
+ * neither a confirmation Teams refuses nor a removal that fails is reported
40
+ * as a failed create, and a create OneUptime refuses keeps the form and says
41
+ * why.
42
+ *
43
+ * And what the review of that fix changed, pinned here end to end as well:
44
+ * the sender of a create command is checked before the form only in a
45
+ * personal chat (in a channel or group chat anyone there may submit it, and
46
+ * the submit checks whoever does); the maintenance form names the zone its
47
+ * times are read in, a submit is read in that zone first, and a reply read at
48
+ * a bare UTC offset says so; a start that has just gone by (five minutes at
49
+ * most) begins now; a submit pointing at a record the project does not have
50
+ * gets a fixed answer that never names another project's record; an
51
+ * unexpected failure links to creating the thing in OneUptime; no form action
52
+ * that happened is reported as failed because Teams refused its confirmation;
53
+ * a question for the AI assistant whose account lookup fails gets the one
54
+ * generic answer, not "connect your account"; and every failure is logged as
55
+ * one line that names the command, never a question's text, followed by the
56
+ * error itself - unless it is a Bot Connector HTTP error, whose status and
57
+ * code say it all.
58
+ *
59
+ * Every other Teams test in Common drives MicrosoftTeamsUtil with a fake
60
+ * TurnContext, because Common's jest config maps botbuilder,
61
+ * botframework-connector and @azure/* to tiny stubs. That cannot show what the
62
+ * Bot Connector does with an oversized reply, nor what the real CloudAdapter
63
+ * answers Teams when a turn throws. So this file runs the REAL botbuilder
64
+ * stack end to end:
65
+ *
66
+ * fake Express req -> MicrosoftTeamsUtil.processBotActivity
67
+ * -> real CloudAdapter.process -> real TeamsActivityHandler
68
+ * -> handleBotMessageActivity -> real TurnContext.sendActivity
69
+ * -> real ConnectorClient (@azure/core-client pipeline) -> HTTP
70
+ * -> fake Bot Connector on 127.0.0.1 (records every POST and DELETE,
71
+ * answers 413 MessageSizeTooBig above a UTF-16 size limit, like Teams,
72
+ * and any other refusal a test asks for)
73
+ * and back: RestError -> handler -> CloudAdapter.process -> HTTP status.
74
+ *
75
+ * How the real botbuilder gets in (the moduleNameMapper stubs win otherwise):
76
+ *
77
+ * - jest.mock("botbuilder", factory) still works although the name is
78
+ * moduleNameMapper-mapped: jest keys explicit mocks by the resolved path, and
79
+ * both the mock registration and every importer resolve "botbuilder" to the
80
+ * same mapped stub path, so the factory result replaces the stub for this
81
+ * file's whole module registry (MicrosoftTeams.ts and Actions/* included).
82
+ *
83
+ * - The factory must load botbuilder with NODE's require, not jest's. Inside
84
+ * jest, require("module") is jest's own shim whose createRequire() returns
85
+ * a jest-registry require - it still applies moduleNameMapper and hands back
86
+ * the 7-key stub. process.getBuiltinModule("module") (Node >= 22.3; CI runs
87
+ * Node 26) is not shimmed and returns the real node:module, so its
88
+ * createRequire() loads botbuilder and all of its dependencies
89
+ * (botbuilder-core, botframework-connector, @azure/*, zod...) through
90
+ * Node's own loader, outside jest's registry and mapper.
91
+ *
92
+ * - Consequence: the real stack lives in the worker's OUTER realm (real Node
93
+ * globals), while the code under test runs in the jsdom test realm. Errors
94
+ * the connector throws are outer-realm Errors, so `err instanceof Error` is
95
+ * FALSE in this file and in the code under test here (it is true in
96
+ * production, where there is one realm). That is one reason the production
97
+ * code recognizes connector errors by their fields (statusCode, code), not
98
+ * by their class. CloudAdapter.process likewise logs with the outer realm's
99
+ * console, which is what outerConsole below watches.
100
+ *
101
+ * Authentication: the adapter is built with no MicrosoftAppId, so
102
+ * botframework-connector reports authentication disabled - a request with no
103
+ * Authorization header is accepted anonymously and outbound connector calls
104
+ * carry no bearer token. Everything after authentication is the production
105
+ * path. The adapter is injected as MicrosoftTeamsUtil's cached adapter, so
106
+ * processBotActivity uses it exactly as it would use the one getBotAdapter()
107
+ * builds (which, like this one, has no onTurnError).
108
+ *
109
+ * Teams redelivery is the one step this file cannot run for real. Microsoft
110
+ * documents that Teams retries a turn that takes over 15 seconds, and resends
111
+ * activities after transient failures; the customer's two identical bubbles a
112
+ * few seconds apart were one such redelivery after our HTTP 500.
113
+ * deliverLikeTeams() simulates it: after a 5xx answer, or no answer at all,
114
+ * it delivers the same activity (same id) once more, as Teams does. The retry
115
+ * of a slow turn is simulated by holding the first delivery up in a database
116
+ * read and delivering the same activity again meanwhile.
117
+ *
118
+ * Only the seams that touch the database are stubbed: the project's lists,
119
+ * tenant resolution, the chat and team install capture, the sender check (or
120
+ * the tables under it: the account link, the memberships and permissions),
121
+ * the dashboard URL, the reference check (or the records it reads) and the
122
+ * final create. The bot drops an activity id it has already handled for ten
123
+ * minutes (in this process's memory, as Redis is not connected here; one test
124
+ * gives the process a Redis client whose connection is down), so every test
125
+ * sends activities with ids of its own - see nextActivityId().
126
+ *
127
+ * The worker runs in Asia/Tokyo (the docblock on the first line), a zone no
128
+ * sender here is in, so a maintenance window read in the server's zone
129
+ * instead of the sender's cannot pass on any machine. A test that needs the
130
+ * time of day stops the test realm's Date alone (stopTheClockAt); timers stay
131
+ * real, and the botbuilder stack reads the outer realm's clock anyway.
132
+ */
133
+
134
+ /*
135
+ * Real botbuilder through Node's loader - see the header. Self-contained,
136
+ * because jest hoists this call above every import and declaration.
137
+ */
138
+ jest.mock("botbuilder", () => {
139
+ const nodeModuleBuiltin: typeof import("module") =
140
+ typeof process.getBuiltinModule === "function"
141
+ ? process.getBuiltinModule("module")
142
+ : // Fallback: jest's shim subclasses the real Module class.
143
+ Object.getPrototypeOf(jest.requireActual("module"));
144
+
145
+ return nodeModuleBuiltin.createRequire(__filename)("botbuilder");
146
+ });
147
+
148
+ jest.mock("../../../../Server/EnvironmentConfig", () => {
149
+ return {
150
+ ...(jest.requireActual("../../../../Server/EnvironmentConfig") as Record<
151
+ string,
152
+ unknown
153
+ >),
154
+ MicrosoftTeamsAppClientId: "11111111-2222-3333-4444-555555555555",
155
+ MicrosoftTeamsAppClientSecret: "test-secret",
156
+ MicrosoftTeamsAppTenantId: "test-tenant",
157
+ };
158
+ });
159
+
160
+ import MicrosoftTeamsUtil, {
161
+ MicrosoftTeamsTenantResolution,
162
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
163
+ import MicrosoftTeamsAuthAction, {
164
+ MicrosoftTeamsAccountNotLinkedException,
165
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/Auth";
166
+ import {
167
+ MicrosoftTeamsAlertActionType,
168
+ MicrosoftTeamsAlertEpisodeActionType,
169
+ MicrosoftTeamsIncidentActionType,
170
+ MicrosoftTeamsIncidentEpisodeActionType,
171
+ MicrosoftTeamsScheduledMaintenanceActionType,
172
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/ActionTypes";
173
+ import {
174
+ MICROSOFT_TEAMS_MAX_CHOICE_TITLE_LENGTH,
175
+ MICROSOFT_TEAMS_MAX_MONITOR_CHOICES,
176
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices";
177
+ import MicrosoftTeamsMessageSize, {
178
+ MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES,
179
+ MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES,
180
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize";
181
+ import MicrosoftTeamsReplies, {
182
+ MICROSOFT_TEAMS_UNAVAILABLE_REFERENCE_MESSAGE,
183
+ } from "../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies";
184
+ import WorkspaceActionAuthorization from "../../../../Server/Utils/Workspace/WorkspaceActionAuthorization";
185
+ import WorkspaceProjectReferenceValidator from "../../../../Server/Utils/Workspace/WorkspaceProjectReferenceValidator";
186
+ import { ProjectScopedReferenceException } from "../../../../Server/Utils/Database/ProjectScopedReferenceValidator";
187
+ import DatabaseConfig from "../../../../Server/DatabaseConfig";
188
+ import GlobalCache from "../../../../Server/Infrastructure/GlobalCache";
189
+ import Redis from "../../../../Server/Infrastructure/Redis";
190
+ import AccessTokenService from "../../../../Server/Services/AccessTokenService";
191
+ import AlertEpisodeInternalNoteService from "../../../../Server/Services/AlertEpisodeInternalNoteService";
192
+ import AlertEpisodeService from "../../../../Server/Services/AlertEpisodeService";
193
+ import AlertInternalNoteService from "../../../../Server/Services/AlertInternalNoteService";
194
+ import AlertService from "../../../../Server/Services/AlertService";
195
+ import IncidentEpisodeInternalNoteService from "../../../../Server/Services/IncidentEpisodeInternalNoteService";
196
+ import IncidentEpisodeService from "../../../../Server/Services/IncidentEpisodeService";
197
+ import IncidentPublicNoteService from "../../../../Server/Services/IncidentPublicNoteService";
198
+ import IncidentService from "../../../../Server/Services/IncidentService";
199
+ import IncidentSeverityService from "../../../../Server/Services/IncidentSeverityService";
200
+ import IncidentStateService from "../../../../Server/Services/IncidentStateService";
201
+ import LabelService from "../../../../Server/Services/LabelService";
202
+ import MonitorService from "../../../../Server/Services/MonitorService";
203
+ import MonitorStatusService from "../../../../Server/Services/MonitorStatusService";
204
+ import OnCallDutyPolicyService from "../../../../Server/Services/OnCallDutyPolicyService";
205
+ import ScheduledMaintenancePublicNoteService from "../../../../Server/Services/ScheduledMaintenancePublicNoteService";
206
+ import ScheduledMaintenanceService from "../../../../Server/Services/ScheduledMaintenanceService";
207
+ import TeamMemberService from "../../../../Server/Services/TeamMemberService";
208
+ import WorkspaceUserAuthTokenService from "../../../../Server/Services/WorkspaceUserAuthTokenService";
209
+ import AlertEpisodeInternalNote from "../../../../Models/DatabaseModels/AlertEpisodeInternalNote";
210
+ import AlertInternalNote from "../../../../Models/DatabaseModels/AlertInternalNote";
211
+ import Incident from "../../../../Models/DatabaseModels/Incident";
212
+ import IncidentEpisodeInternalNote from "../../../../Models/DatabaseModels/IncidentEpisodeInternalNote";
213
+ import IncidentPublicNote from "../../../../Models/DatabaseModels/IncidentPublicNote";
214
+ import IncidentSeverity from "../../../../Models/DatabaseModels/IncidentSeverity";
215
+ import IncidentState from "../../../../Models/DatabaseModels/IncidentState";
216
+ import Label from "../../../../Models/DatabaseModels/Label";
217
+ import Monitor from "../../../../Models/DatabaseModels/Monitor";
218
+ import MonitorStatus from "../../../../Models/DatabaseModels/MonitorStatus";
219
+ import OnCallDutyPolicy from "../../../../Models/DatabaseModels/OnCallDutyPolicy";
220
+ import ScheduledMaintenance from "../../../../Models/DatabaseModels/ScheduledMaintenance";
221
+ import ScheduledMaintenancePublicNote from "../../../../Models/DatabaseModels/ScheduledMaintenancePublicNote";
222
+ import TeamMember from "../../../../Models/DatabaseModels/TeamMember";
223
+ import WorkspaceProjectAuthToken from "../../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
224
+ import URL from "../../../../Types/API/URL";
225
+ import BadDataException from "../../../../Types/Exception/BadDataException";
226
+ import NotAuthorizedException from "../../../../Types/Exception/NotAuthorizedException";
227
+ import DatabaseCommonInteractionProps from "../../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
228
+ import { JSONObject } from "../../../../Types/JSON";
229
+ import ObjectID from "../../../../Types/ObjectID";
230
+ import Permission, {
231
+ UserPermission,
232
+ UserTenantAccessPermission,
233
+ } from "../../../../Types/Permission";
234
+ import PositiveNumber from "../../../../Types/PositiveNumber";
235
+ import ErrorClass from "../../../../Types/Telemetry/ErrorClass";
236
+ import { ERROR_CLASS_ATTRIBUTE_KEY } from "../../../../Types/Telemetry/UnitOfWork";
237
+ import {
238
+ ExpressRequest,
239
+ ExpressResponse,
240
+ } from "../../../../Server/Utils/Express";
241
+ import logger, { LogAttributes } from "../../../../Server/Utils/Logger";
242
+ import {
243
+ Activity,
244
+ CloudAdapter,
245
+ ConfigurationBotFrameworkAuthentication,
246
+ ResourceResponse,
247
+ TeamsActivityHandler,
248
+ TeamsInfo,
249
+ TurnContext,
250
+ } from "botbuilder";
251
+
252
+ // ---- Real Node (the outer realm) ----
253
+
254
+ type NodeModuleBuiltin = typeof import("module");
255
+
256
+ function getRealNodeModuleBuiltin(): NodeModuleBuiltin {
257
+ if (typeof process.getBuiltinModule === "function") {
258
+ return process.getBuiltinModule("module");
259
+ }
260
+
261
+ return Object.getPrototypeOf(
262
+ jest.requireActual("module"),
263
+ ) as NodeModuleBuiltin;
264
+ }
265
+
266
+ // Node's own require, rooted at this file: outside jest's registry and mapper.
267
+ const nativeRequire: NodeJS.Require =
268
+ getRealNodeModuleBuiltin().createRequire(__filename);
269
+
270
+ /*
271
+ * The outer realm's console: CloudAdapter.process console.error()s a turn
272
+ * that threw, just before it answers Teams with HTTP 500. (jest replaces only
273
+ * the test realm's console.)
274
+ */
275
+ const outerConsole: Console = nativeRequire("console") as Console;
276
+
277
+ // ---- Fake Bot Connector ----
278
+
279
+ /*
280
+ * Microsoft Learn ("Message size limits"): a bot message may be about 100 KB,
281
+ * measured as UTF-16, and Teams refuses a larger one with HTTP 413 and code
282
+ * MessageSizeTooBig. Tests lower the limit to make Teams refuse a card.
283
+ */
284
+ const TEAMS_MESSAGE_LIMIT_IN_UTF16_BYTES: number = 100 * 1024;
285
+
286
+ /*
287
+ * What the fixed forms are built to: the first card budget, 40 KiB. Written
288
+ * out rather than read from MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES, so a
289
+ * looser budget fails here instead of moving the bar with it.
290
+ */
291
+ const CARD_BUDGET_IN_UTF16_BYTES: number = 40 * 1024;
292
+
293
+ // A card's or a text's size as Teams measures it: UTF-16, two bytes a unit.
294
+ function toUtf16Bytes(value: string | JSONObject): number {
295
+ return (typeof value === "string" ? value : JSON.stringify(value)).length * 2;
296
+ }
297
+
298
+ const MESSAGE_SIZE_TOO_BIG_BODY: JSONObject = {
299
+ error: { code: "MessageSizeTooBig", message: "Message size too large." },
300
+ };
301
+
302
+ const ACTIVITY_NOT_FOUND_BODY: JSONObject = {
303
+ error: { code: "NotFound", message: "The activity could not be found." },
304
+ };
305
+
306
+ // Send, reply and delete, as botframework-connector calls them.
307
+ const ACTIVITIES_PATH: RegExp =
308
+ /^\/v3\/conversations\/([^/]+)\/activities(?:\/([^/]+))?$/;
309
+
310
+ interface ConnectorRequestRecord {
311
+ method: string;
312
+ path: string;
313
+ conversationId: string | null;
314
+ // POST: the activity replied to. DELETE: the activity removed.
315
+ activityIdInPath: string | null;
316
+ // The request body's size as Teams measures it: UTF-16, two bytes a unit.
317
+ wireUtf16Bytes: number;
318
+ answeredStatus: number;
319
+ answeredBody: JSONObject;
320
+ activity: JSONObject | null;
321
+ }
322
+
323
+ interface ConnectorAnswer {
324
+ status: number;
325
+ body: JSONObject;
326
+ }
327
+
328
+ // A request to the activities API, as a refusal rule sees it.
329
+ interface ConnectorRequest {
330
+ method: string;
331
+ activity: JSONObject | null;
332
+ }
333
+
334
+ /*
335
+ * Refusals the connector gives up on at once (its pipeline retries 408, 5xx
336
+ * but 501 and 505, and a 429 or 503 that says when to retry): Teams refusing
337
+ * a message outright, and refusing a card for a reason other than its size.
338
+ */
339
+ const MESSAGE_REFUSED_ANSWER: ConnectorAnswer = {
340
+ status: 403,
341
+ body: { error: { code: "Forbidden", message: "The message was refused." } },
342
+ };
343
+
344
+ const CARD_REFUSED_ANSWER: ConnectorAnswer = {
345
+ status: 400,
346
+ body: { error: { code: "BadArgument", message: "The card was refused." } },
347
+ };
348
+
349
+ // Whether a request posts an adaptive card, such as the form.
350
+ function isCardPost(request: ConnectorRequest): boolean {
351
+ return (
352
+ request.method === "POST" &&
353
+ asObjectArray(request.activity?.["attachments"]).length > 0
354
+ );
355
+ }
356
+
357
+ interface FakeBotConnector {
358
+ serviceUrl: string;
359
+ requests: Array<ConnectorRequestRecord>;
360
+ // A POST larger than this is refused with 413 MessageSizeTooBig.
361
+ maxUtf16Bytes: number;
362
+ // What a DELETE of an activity is answered with.
363
+ deleteStatus: number;
364
+ /*
365
+ * When set, a request this returns an answer for is refused with it, e.g.
366
+ * to make Teams refuse one particular message.
367
+ */
368
+ refuse: ((request: ConnectorRequest) => ConnectorAnswer | null) | null;
369
+ close: () => Promise<void>;
370
+ }
371
+
372
+ async function startFakeBotConnector(): Promise<FakeBotConnector> {
373
+ let nextMessageNumber: number = 0;
374
+
375
+ const fakeConnector: FakeBotConnector = {
376
+ serviceUrl: "",
377
+ requests: [],
378
+ maxUtf16Bytes: TEAMS_MESSAGE_LIMIT_IN_UTF16_BYTES,
379
+ deleteStatus: 200,
380
+ refuse: null,
381
+ close: async (): Promise<void> => {
382
+ return undefined;
383
+ },
384
+ };
385
+
386
+ const server: http.Server = http.createServer(
387
+ (req: http.IncomingMessage, res: http.ServerResponse) => {
388
+ let bodyText: string = "";
389
+ req.setEncoding("utf8");
390
+ req.on("data", (chunk: string) => {
391
+ bodyText += chunk;
392
+ });
393
+ req.on("end", () => {
394
+ const path: string = (req.url || "/").split("?")[0] || "/";
395
+ const match: RegExpExecArray | null = ACTIVITIES_PATH.exec(path);
396
+ const wireUtf16Bytes: number = bodyText.length * 2;
397
+
398
+ let activity: JSONObject | null = null;
399
+ try {
400
+ activity = bodyText ? (JSON.parse(bodyText) as JSONObject) : null;
401
+ } catch {
402
+ activity = null;
403
+ }
404
+
405
+ let answeredStatus: number = 404;
406
+ let answeredBody: JSONObject = {
407
+ error: {
408
+ code: "NotFound",
409
+ message: `The fake connector has no route for ${req.method} ${path}`,
410
+ },
411
+ };
412
+
413
+ const refusal: ConnectorAnswer | null =
414
+ match && fakeConnector.refuse
415
+ ? fakeConnector.refuse({
416
+ method: req.method || "",
417
+ activity: activity,
418
+ })
419
+ : null;
420
+
421
+ if (refusal) {
422
+ answeredStatus = refusal.status;
423
+ answeredBody = refusal.body;
424
+ } else if (req.method === "POST" && match) {
425
+ if (wireUtf16Bytes > fakeConnector.maxUtf16Bytes) {
426
+ answeredStatus = 413;
427
+ answeredBody = MESSAGE_SIZE_TOO_BIG_BODY;
428
+ } else {
429
+ nextMessageNumber++;
430
+ answeredStatus = 200;
431
+ answeredBody = { id: `msg-${nextMessageNumber}` };
432
+ }
433
+ } else if (req.method === "DELETE" && match?.[2]) {
434
+ answeredStatus = fakeConnector.deleteStatus;
435
+ answeredBody =
436
+ fakeConnector.deleteStatus < 300 ? {} : ACTIVITY_NOT_FOUND_BODY;
437
+ }
438
+
439
+ fakeConnector.requests.push({
440
+ method: req.method || "",
441
+ path: path,
442
+ conversationId: match?.[1] ? decodeURIComponent(match[1]) : null,
443
+ activityIdInPath: match?.[2] ? decodeURIComponent(match[2]) : null,
444
+ wireUtf16Bytes: wireUtf16Bytes,
445
+ answeredStatus: answeredStatus,
446
+ answeredBody: answeredBody,
447
+ activity: activity,
448
+ });
449
+
450
+ res.writeHead(answeredStatus, {
451
+ "Content-Type": "application/json; charset=utf-8",
452
+ });
453
+ res.end(JSON.stringify(answeredBody));
454
+ });
455
+ },
456
+ );
457
+
458
+ await new Promise<void>((resolve: () => void) => {
459
+ server.listen(0, "127.0.0.1", () => {
460
+ resolve();
461
+ });
462
+ });
463
+
464
+ const address: AddressInfo = server.address() as AddressInfo;
465
+ fakeConnector.serviceUrl = `http://127.0.0.1:${address.port}/`;
466
+ fakeConnector.close = async (): Promise<void> => {
467
+ server.closeAllConnections();
468
+ await new Promise<void>((resolve: () => void) => {
469
+ server.close(() => {
470
+ resolve();
471
+ });
472
+ });
473
+ };
474
+
475
+ return fakeConnector;
476
+ }
477
+
478
+ // ---- Fake Express response: what Teams gets back ----
479
+
480
+ class RecordingResponse {
481
+ // Null until the bot answers, so a delivery nobody answered never reads as 200.
482
+ public statusCode: number | null = null;
483
+ public body: unknown = undefined;
484
+ public ended: boolean = false;
485
+ public headersSent: boolean = false;
486
+ public headers: Record<string, string> = {};
487
+
488
+ public status(code: number): this {
489
+ this.statusCode = code;
490
+ return this;
491
+ }
492
+
493
+ public send(body?: unknown): this {
494
+ this.body = body;
495
+ this.headersSent = true;
496
+ return this;
497
+ }
498
+
499
+ public json(body?: unknown): this {
500
+ this.body = body;
501
+ this.headersSent = true;
502
+ return this;
503
+ }
504
+
505
+ public end(): this {
506
+ this.ended = true;
507
+ this.headersSent = true;
508
+ return this;
509
+ }
510
+
511
+ public header(name: string, value: string): this {
512
+ this.headers[name.toLowerCase()] = value;
513
+ return this;
514
+ }
515
+
516
+ public setHeader(name: string, value: string): this {
517
+ return this.header(name, value);
518
+ }
519
+ }
520
+
521
+ // ---- The real adapter under test ----
522
+
523
+ type CachedAdapterSlot = { cachedAdapter: CloudAdapter | null };
524
+
525
+ interface RealAdapterUnderTest {
526
+ adapter: CloudAdapter;
527
+ // Every error that escaped adapter.sendActivities, i.e. turnContext.sendActivity.
528
+ sendActivitiesErrors: Array<unknown>;
529
+ }
530
+
531
+ function installRealCloudAdapter(): RealAdapterUnderTest {
532
+ const adapter: CloudAdapter = new CloudAdapter(
533
+ new ConfigurationBotFrameworkAuthentication({}),
534
+ );
535
+
536
+ const sendActivitiesErrors: Array<unknown> = [];
537
+ const realSendActivities: CloudAdapter["sendActivities"] =
538
+ adapter.sendActivities.bind(adapter);
539
+
540
+ // A transparent tap: it records and rethrows, so behaviour is unchanged.
541
+ adapter.sendActivities = async (
542
+ context: TurnContext,
543
+ activities: Array<Partial<Activity>>,
544
+ ): Promise<Array<ResourceResponse>> => {
545
+ try {
546
+ return await realSendActivities(context, activities);
547
+ } catch (error) {
548
+ sendActivitiesErrors.push(error);
549
+ throw error;
550
+ }
551
+ };
552
+
553
+ (MicrosoftTeamsUtil as unknown as CachedAdapterSlot).cachedAdapter = adapter;
554
+
555
+ return { adapter, sendActivitiesErrors };
556
+ }
557
+
558
+ // ---- The project behind the bot (only the database seams are stubbed) ----
559
+
560
+ const TENANT_ID: string = "4111aaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
561
+ const BOT_ID: string = "28:oneuptime-bot-app-id";
562
+ const BOT_NAME: string = "OneUptime";
563
+ const SENDER_AAD_OBJECT_ID: string = "00000000-0000-4000-8000-000000004111";
564
+ const PERSONAL_CONVERSATION_ID: string = "a:4111-personal-chat";
565
+ const GROUP_CONVERSATION_ID: string = "19:group-chat-4111@thread.v2";
566
+ const CHANNEL_CONVERSATION_ID: string = "19:ops-channel-4111@thread.tacv2";
567
+ const TEAM_ID: string = "19:ops-team-4111@thread.tacv2";
568
+ const SENDER_TIMEZONE: string = "America/New_York";
569
+ const DASHBOARD_URL: string = "https://oneuptime.example.com/dashboard";
570
+ const ONEUPTIME_USER_ID: ObjectID = ObjectID.generate();
571
+
572
+ const INCIDENT_ADD_LATER_HINT: string =
573
+ "You can add them to the incident in OneUptime after it is created.";
574
+ const MAINTENANCE_ADD_LATER_HINT: string =
575
+ "You can add them to the event in OneUptime after it is created.";
576
+
577
+ type NamedRow = { _id: string; name: string };
578
+
579
+ interface ProjectShape {
580
+ monitors: number;
581
+ labels?: number | undefined;
582
+ onCallPolicies?: number | undefined;
583
+ severities?: number | undefined;
584
+ monitorStatuses?: number | undefined;
585
+ // Monitor, label and on-call policy names of 100 characters.
586
+ longNames?: boolean | undefined;
587
+ }
588
+
589
+ // Every record the project has, in the order the bot reads them.
590
+ interface ProjectData {
591
+ monitors: Array<NamedRow>;
592
+ labels: Array<NamedRow>;
593
+ onCallPolicies: Array<NamedRow>;
594
+ severities: Array<NamedRow>;
595
+ monitorStatuses: Array<NamedRow>;
596
+ }
597
+
598
+ const LONG_NAME_TAIL: string =
599
+ " - checkout, payments and identity services in eu-west-1, us-east-1 and ap-southeast-2";
600
+
601
+ function padded(index: number, width: number): string {
602
+ return String(index).padStart(width, "0");
603
+ }
604
+
605
+ function toLongName(name: string): string {
606
+ return (name + LONG_NAME_TAIL + LONG_NAME_TAIL).substring(0, 100);
607
+ }
608
+
609
+ function namedRows(
610
+ count: number,
611
+ nameFor: (index: number) => string,
612
+ ): Array<NamedRow> {
613
+ const rows: Array<NamedRow> = [];
614
+ for (let index: number = 1; index <= count; index++) {
615
+ rows.push({ _id: ObjectID.generate().toString(), name: nameFor(index) });
616
+ }
617
+ return rows;
618
+ }
619
+
620
+ /*
621
+ * The rows a findBy for this query returns: the project's rows (generated in
622
+ * the query's sort order) up to the query's limit, as the database would.
623
+ */
624
+ function firstRowsFor(rows: Array<NamedRow>, findBy: unknown): Array<NamedRow> {
625
+ const limit: unknown = (findBy as { limit?: unknown } | undefined)?.limit;
626
+
627
+ if (typeof limit === "number") {
628
+ return rows.slice(0, limit);
629
+ }
630
+
631
+ if (limit instanceof PositiveNumber) {
632
+ return rows.slice(0, limit.toNumber());
633
+ }
634
+
635
+ return rows;
636
+ }
637
+
638
+ function stubProjectData(shape: ProjectShape): ProjectData {
639
+ const name: (value: string) => string = (value: string): string => {
640
+ return shape.longNames ? toLongName(value) : value;
641
+ };
642
+
643
+ const data: ProjectData = {
644
+ // 30 characters each, e.g. "Production HTTP Monitor 000001".
645
+ monitors: namedRows(shape.monitors, (index: number) => {
646
+ return name(`Production HTTP Monitor ${padded(index, 6)}`);
647
+ }),
648
+ labels: namedRows(shape.labels ?? 20, (index: number) => {
649
+ return name(`team:platform-${padded(index, 3)}`);
650
+ }),
651
+ onCallPolicies: namedRows(shape.onCallPolicies ?? 10, (index: number) => {
652
+ return name(`Primary On-Call Policy ${padded(index, 3)}`);
653
+ }),
654
+ severities: namedRows(shape.severities ?? 4, (index: number) => {
655
+ return name(`Severity Level ${padded(index, 2)}`);
656
+ }),
657
+ monitorStatuses: namedRows(shape.monitorStatuses ?? 4, (index: number) => {
658
+ return name(
659
+ ["Operational", "Degraded", "Offline", "Under Maintenance"][
660
+ index - 1
661
+ ] || `Status ${padded(index, 2)}`,
662
+ );
663
+ }),
664
+ };
665
+
666
+ // Re-spying a spied method returns the same spy, so this just swaps rows.
667
+ jest.spyOn(MonitorService, "findBy").mockImplementation((findBy: unknown) => {
668
+ return Promise.resolve(
669
+ firstRowsFor(data.monitors, findBy) as unknown as Array<Monitor>,
670
+ );
671
+ });
672
+ jest
673
+ .spyOn(MonitorService, "countBy")
674
+ .mockResolvedValue(new PositiveNumber(data.monitors.length));
675
+
676
+ jest.spyOn(LabelService, "findBy").mockImplementation((findBy: unknown) => {
677
+ return Promise.resolve(
678
+ firstRowsFor(data.labels, findBy) as unknown as Array<Label>,
679
+ );
680
+ });
681
+ jest
682
+ .spyOn(LabelService, "countBy")
683
+ .mockResolvedValue(new PositiveNumber(data.labels.length));
684
+
685
+ jest
686
+ .spyOn(OnCallDutyPolicyService, "findBy")
687
+ .mockImplementation((findBy: unknown) => {
688
+ return Promise.resolve(
689
+ firstRowsFor(
690
+ data.onCallPolicies,
691
+ findBy,
692
+ ) as unknown as Array<OnCallDutyPolicy>,
693
+ );
694
+ });
695
+ jest
696
+ .spyOn(OnCallDutyPolicyService, "countBy")
697
+ .mockResolvedValue(new PositiveNumber(data.onCallPolicies.length));
698
+
699
+ jest
700
+ .spyOn(IncidentSeverityService, "findBy")
701
+ .mockImplementation((findBy: unknown) => {
702
+ return Promise.resolve(
703
+ firstRowsFor(
704
+ data.severities,
705
+ findBy,
706
+ ) as unknown as Array<IncidentSeverity>,
707
+ );
708
+ });
709
+
710
+ jest
711
+ .spyOn(MonitorStatusService, "findBy")
712
+ .mockImplementation((findBy: unknown) => {
713
+ return Promise.resolve(
714
+ firstRowsFor(
715
+ data.monitorStatuses,
716
+ findBy,
717
+ ) as unknown as Array<MonitorStatus>,
718
+ );
719
+ });
720
+
721
+ return data;
722
+ }
723
+
724
+ function stubTenantAndInstallCapture(forProjectId: ObjectID): void {
725
+ jest.spyOn(MicrosoftTeamsUtil, "resolveProjectByTenantId").mockResolvedValue({
726
+ projectAuth: {
727
+ projectId: forProjectId,
728
+ } as unknown as WorkspaceProjectAuthToken,
729
+ isAmbiguous: false,
730
+ candidateProjectIds: [forProjectId],
731
+ } as MicrosoftTeamsTenantResolution);
732
+
733
+ jest
734
+ .spyOn(
735
+ MicrosoftTeamsUtil as unknown as {
736
+ captureChatFromBotActivity: () => Promise<void>;
737
+ },
738
+ "captureChatFromBotActivity",
739
+ )
740
+ .mockResolvedValue(undefined);
741
+
742
+ jest
743
+ .spyOn(MicrosoftTeamsUtil, "captureTeamFromBotActivity")
744
+ .mockResolvedValue(undefined);
745
+ }
746
+
747
+ // The sender has a connected account and is a member who may create things.
748
+ function stubSenderCheck(forProjectId: ObjectID): void {
749
+ jest
750
+ .spyOn(MicrosoftTeamsAuthAction, "getOneUptimeUserIdFromTeamsUserId")
751
+ .mockResolvedValue(ONEUPTIME_USER_ID);
752
+
753
+ jest
754
+ .spyOn(WorkspaceActionAuthorization, "getProjectMemberProps")
755
+ .mockResolvedValue({
756
+ userId: ONEUPTIME_USER_ID,
757
+ tenantId: forProjectId,
758
+ } as DatabaseCommonInteractionProps);
759
+
760
+ jest
761
+ .spyOn(WorkspaceActionAuthorization, "assertCanCreate")
762
+ .mockResolvedValue(undefined);
763
+ }
764
+
765
+ /*
766
+ * The real membership and permission checks (getProjectMemberProps and
767
+ * assertCanCreate) in place of the stubs above, over the reads under them:
768
+ * the sender's accepted memberships and their permissions in the project.
769
+ * Null: the linked user holds no accepted membership in the project any
770
+ * more. assertCanCreate stays watched.
771
+ */
772
+ function useRealMembershipCheck(permissions: Array<Permission> | null): void {
773
+ jest
774
+ .spyOn(WorkspaceActionAuthorization, "getProjectMemberProps")
775
+ .mockRestore();
776
+ jest.spyOn(WorkspaceActionAuthorization, "assertCanCreate").mockRestore();
777
+ jest.spyOn(WorkspaceActionAuthorization, "assertCanCreate");
778
+
779
+ const memberships: Array<TeamMember> = [];
780
+ if (permissions) {
781
+ const membership: TeamMember = new TeamMember();
782
+ membership.teamId = ObjectID.generate();
783
+ memberships.push(membership);
784
+ }
785
+
786
+ const tenantPermission: UserTenantAccessPermission | null = permissions
787
+ ? {
788
+ _type: "UserTenantAccessPermission",
789
+ projectId: projectId,
790
+ permissions: permissions.map(
791
+ (permission: Permission): UserPermission => {
792
+ return {
793
+ _type: "UserPermission",
794
+ permission: permission,
795
+ isBlockPermission: false,
796
+ labelIds: [],
797
+ };
798
+ },
799
+ ),
800
+ }
801
+ : null;
802
+
803
+ jest.spyOn(TeamMemberService, "findBy").mockResolvedValue(memberships);
804
+ jest
805
+ .spyOn(AccessTokenService, "getUserGlobalAccessPermission")
806
+ .mockResolvedValue(null);
807
+ jest
808
+ .spyOn(AccessTokenService, "getUserTenantAccessPermission")
809
+ .mockResolvedValue(tenantPermission);
810
+ }
811
+
812
+ function stubDashboardUrl(): void {
813
+ jest.spyOn(DatabaseConfig, "getDashboardUrl").mockImplementation(() => {
814
+ return Promise.resolve(URL.fromString(DASHBOARD_URL));
815
+ });
816
+ }
817
+
818
+ // ---- Teams activities ----
819
+
820
+ let activityCounter: number = 0;
821
+
822
+ /*
823
+ * Unique to this run, so an id claimed by an earlier run can never be taken
824
+ * for a redelivery, even by a claim store that outlives the process.
825
+ */
826
+ const RUN_TOKEN: string = ObjectID.generate().toString().substring(0, 8);
827
+
828
+ /*
829
+ * A fresh activity id. The bot drops an id it has seen in the same
830
+ * conversation (see the header), so tests must not share one.
831
+ */
832
+ function nextActivityId(label: string): string {
833
+ activityCounter++;
834
+ return `4111-${RUN_TOKEN}-${label}-${activityCounter}`;
835
+ }
836
+
837
+ type ConversationKind = "personal" | "groupChat" | "channel";
838
+
839
+ const SENDER: JSONObject = {
840
+ id: "29:1customer-user",
841
+ name: "Customer User",
842
+ aadObjectId: SENDER_AAD_OBJECT_ID,
843
+ };
844
+
845
+ // Someone else in a group chat or channel, who fills in a form posted there.
846
+ const OTHER_MEMBER: JSONObject = {
847
+ id: "29:1another-user",
848
+ name: "Another User",
849
+ aadObjectId: "00000000-0000-4000-8000-000000004112",
850
+ };
851
+
852
+ const BOT: JSONObject = { id: BOT_ID, name: BOT_NAME };
853
+
854
+ function buildConversation(kind: ConversationKind): JSONObject {
855
+ if (kind === "groupChat") {
856
+ return {
857
+ conversationType: "groupChat",
858
+ tenantId: TENANT_ID,
859
+ id: GROUP_CONVERSATION_ID,
860
+ isGroup: true,
861
+ };
862
+ }
863
+
864
+ if (kind === "channel") {
865
+ return {
866
+ conversationType: "channel",
867
+ tenantId: TENANT_ID,
868
+ id: CHANNEL_CONVERSATION_ID,
869
+ isGroup: true,
870
+ };
871
+ }
872
+
873
+ return {
874
+ conversationType: "personal",
875
+ tenantId: TENANT_ID,
876
+ id: PERSONAL_CONVERSATION_ID,
877
+ };
878
+ }
879
+
880
+ // What Teams puts in channelData: the tenant, and in a channel its team.
881
+ function buildChannelData(kind: ConversationKind): JSONObject {
882
+ return kind === "channel"
883
+ ? {
884
+ tenant: { id: TENANT_ID },
885
+ team: { id: TEAM_ID, name: "Operations" },
886
+ channel: { id: CHANNEL_CONVERSATION_ID, name: "incidents" },
887
+ }
888
+ : { tenant: { id: TENANT_ID } };
889
+ }
890
+
891
+ // Teams' clientInfo entity; it names the sender's zone when Teams knows it.
892
+ function buildClientInfo(timezone: string | undefined): JSONObject {
893
+ return {
894
+ type: "clientInfo",
895
+ locale: "en-US",
896
+ country: "US",
897
+ platform: "Web",
898
+ ...(timezone ? { timezone: timezone } : {}),
899
+ };
900
+ }
901
+
902
+ /*
903
+ * A message typed to the bot: in a 1:1 chat, or with an @mention in a group
904
+ * chat or a channel.
905
+ */
906
+ function buildMessageActivity(options: {
907
+ id: string;
908
+ text: string;
909
+ conversation?: ConversationKind | undefined;
910
+ // The IANA zone Teams puts on the activity itself.
911
+ localTimezone?: string | undefined;
912
+ // The zone in the clientInfo entity: localTimezone's, unless given (even as undefined).
913
+ clientInfoTimezone?: string | undefined;
914
+ }): JSONObject {
915
+ const conversation: ConversationKind = options.conversation || "personal";
916
+ const isMention: boolean = conversation !== "personal";
917
+ const timezone: string | undefined = options.localTimezone;
918
+ const clientInfoTimezone: string | undefined =
919
+ "clientInfoTimezone" in options ? options.clientInfoTimezone : timezone;
920
+
921
+ return {
922
+ type: "message",
923
+ id: options.id,
924
+ timestamp: "2026-09-29T10:00:00.000Z",
925
+ localTimestamp: "2026-09-29T06:00:00.000-04:00",
926
+ ...(timezone ? { localTimezone: timezone } : {}),
927
+ serviceUrl: connector.serviceUrl,
928
+ channelId: "msteams",
929
+ from: SENDER,
930
+ conversation: buildConversation(conversation),
931
+ recipient: BOT,
932
+ textFormat: "plain",
933
+ locale: "en-US",
934
+ text: isMention ? `<at>${BOT_NAME}</at> ${options.text}` : options.text,
935
+ entities: isMention
936
+ ? [
937
+ {
938
+ type: "mention",
939
+ text: `<at>${BOT_NAME}</at>`,
940
+ mentioned: BOT,
941
+ },
942
+ buildClientInfo(clientInfoTimezone),
943
+ ]
944
+ : [buildClientInfo(clientInfoTimezone)],
945
+ channelData: buildChannelData(conversation),
946
+ };
947
+ }
948
+
949
+ /*
950
+ * What Teams sends when the user presses a card's Action.Submit: a message
951
+ * with no text and no @mention (in a group chat too), replying to the card,
952
+ * whose value holds the inputs and the action's data.
953
+ */
954
+ function buildCardSubmitActivity(options: {
955
+ id: string;
956
+ replyToId: string;
957
+ value: JSONObject;
958
+ conversation?: ConversationKind | undefined;
959
+ // Who pressed the button: the sender, unless someone else in the chat did.
960
+ from?: JSONObject | undefined;
961
+ localTimezone?: string | undefined;
962
+ localTimestamp?: string | undefined;
963
+ }): JSONObject {
964
+ const conversation: ConversationKind = options.conversation || "personal";
965
+ const timezone: string | undefined = options.localTimezone;
966
+
967
+ return {
968
+ type: "message",
969
+ id: options.id,
970
+ replyToId: options.replyToId,
971
+ timestamp: "2026-09-29T10:05:00.000Z",
972
+ localTimestamp: options.localTimestamp || "2026-09-29T06:05:00.000-04:00",
973
+ ...(timezone ? { localTimezone: timezone } : {}),
974
+ serviceUrl: connector.serviceUrl,
975
+ channelId: "msteams",
976
+ from: options.from || SENDER,
977
+ conversation: buildConversation(conversation),
978
+ recipient: BOT,
979
+ locale: "en-US",
980
+ entities: [buildClientInfo(timezone)],
981
+ channelData: {
982
+ ...buildChannelData(conversation),
983
+ source: { name: "message" },
984
+ legacy: { replyToId: options.replyToId },
985
+ },
986
+ value: options.value,
987
+ };
988
+ }
989
+
990
+ // An Adaptive Card Universal Action (Action.Execute) arrives as an invoke.
991
+ function buildInvokeActivity(id: string): JSONObject {
992
+ return {
993
+ type: "invoke",
994
+ name: "adaptiveCard/action",
995
+ id: id,
996
+ timestamp: "2026-09-29T10:10:00.000Z",
997
+ serviceUrl: connector.serviceUrl,
998
+ channelId: "msteams",
999
+ from: SENDER,
1000
+ conversation: buildConversation("personal"),
1001
+ recipient: BOT,
1002
+ locale: "en-US",
1003
+ value: {
1004
+ action: {
1005
+ type: "Action.Execute",
1006
+ verb: MicrosoftTeamsIncidentActionType.AckIncident,
1007
+ data: {
1008
+ action: MicrosoftTeamsIncidentActionType.AckIncident,
1009
+ actionValue: ObjectID.generate().toString(),
1010
+ },
1011
+ },
1012
+ trigger: "manual",
1013
+ },
1014
+ channelData: { tenant: { id: TENANT_ID }, source: { name: "message" } },
1015
+ };
1016
+ }
1017
+
1018
+ type BotAddedActivityType = "conversationUpdate" | "installationUpdate";
1019
+
1020
+ /*
1021
+ * The bot added to a group chat, as Teams announces it: a conversationUpdate
1022
+ * naming the bot among the members added, and an installationUpdate "add".
1023
+ */
1024
+ function buildBotAddedActivity(
1025
+ type: BotAddedActivityType,
1026
+ id: string,
1027
+ ): JSONObject {
1028
+ return {
1029
+ type: type,
1030
+ id: id,
1031
+ timestamp: "2026-09-29T10:15:00.000Z",
1032
+ serviceUrl: connector.serviceUrl,
1033
+ channelId: "msteams",
1034
+ from: SENDER,
1035
+ conversation: buildConversation("groupChat"),
1036
+ recipient: BOT,
1037
+ ...(type === "conversationUpdate"
1038
+ ? { membersAdded: [BOT] }
1039
+ : { action: "add" }),
1040
+ channelData: { tenant: { id: TENANT_ID } },
1041
+ };
1042
+ }
1043
+
1044
+ // ---- Delivery, as Teams does it ----
1045
+
1046
+ interface ConnectorCall {
1047
+ method: string;
1048
+ kind: "card" | "text" | "delete" | "other";
1049
+ // A card's heading (first TextBlock), a text's text, a DELETE's target id.
1050
+ label: string;
1051
+ text: string | null;
1052
+ card: JSONObject | null;
1053
+ /*
1054
+ * The card alone, measured here rather than by MicrosoftTeamsMessageSize,
1055
+ * so a wrong measure in the code under test cannot pass its own check.
1056
+ */
1057
+ cardUtf16Bytes: number | null;
1058
+ // The whole request body, as Teams measures it.
1059
+ wireUtf16Bytes: number;
1060
+ connectorStatus: number;
1061
+ accepted: boolean;
1062
+ conversationId: string | null;
1063
+ activityIdInPath: string | null;
1064
+ // The id the connector gave an accepted message; a card submit replies to it.
1065
+ messageId: string | null;
1066
+ }
1067
+
1068
+ interface DeliveryRecord {
1069
+ // Null when the bot never answered the delivery.
1070
+ httpStatus: number | null;
1071
+ httpBody: unknown;
1072
+ // Everything the bot asked the connector to do while handling it.
1073
+ calls: Array<ConnectorCall>;
1074
+ }
1075
+
1076
+ function asObject(value: unknown): JSONObject {
1077
+ return value && typeof value === "object" && !Array.isArray(value)
1078
+ ? (value as JSONObject)
1079
+ : {};
1080
+ }
1081
+
1082
+ function asObjectArray(value: unknown): Array<JSONObject> {
1083
+ return Array.isArray(value) ? (value as Array<JSONObject>) : [];
1084
+ }
1085
+
1086
+ function summarizeCall(record: ConnectorRequestRecord): ConnectorCall {
1087
+ const activity: JSONObject = record.activity || {};
1088
+ const firstAttachment: JSONObject | undefined = asObjectArray(
1089
+ activity["attachments"],
1090
+ )[0];
1091
+ const card: JSONObject | null =
1092
+ firstAttachment &&
1093
+ firstAttachment["contentType"] === "application/vnd.microsoft.card.adaptive"
1094
+ ? asObject(firstAttachment["content"])
1095
+ : null;
1096
+ const text: string | null =
1097
+ typeof activity["text"] === "string" ? activity["text"] : null;
1098
+ const accepted: boolean =
1099
+ record.answeredStatus >= 200 && record.answeredStatus < 300;
1100
+
1101
+ let kind: ConnectorCall["kind"] = "other";
1102
+ let label: string = `${record.method} ${record.path}`;
1103
+
1104
+ if (record.method === "DELETE") {
1105
+ kind = "delete";
1106
+ label = record.activityIdInPath || "";
1107
+ } else if (card) {
1108
+ kind = "card";
1109
+ label = getTextBlocks(card)[0] || "";
1110
+ } else if (text !== null) {
1111
+ kind = "text";
1112
+ label = text;
1113
+ }
1114
+
1115
+ return {
1116
+ method: record.method,
1117
+ kind: kind,
1118
+ label: label,
1119
+ text: text,
1120
+ card: card,
1121
+ cardUtf16Bytes: card ? toUtf16Bytes(card) : null,
1122
+ wireUtf16Bytes: record.wireUtf16Bytes,
1123
+ connectorStatus: record.answeredStatus,
1124
+ accepted: accepted,
1125
+ conversationId: record.conversationId,
1126
+ activityIdInPath: record.activityIdInPath,
1127
+ messageId:
1128
+ record.method === "POST" &&
1129
+ accepted &&
1130
+ typeof record.answeredBody["id"] === "string"
1131
+ ? record.answeredBody["id"]
1132
+ : null,
1133
+ };
1134
+ }
1135
+
1136
+ async function deliverOnce(activity: JSONObject): Promise<DeliveryRecord> {
1137
+ const firstRequestIndex: number = connector.requests.length;
1138
+
1139
+ const req: ExpressRequest = {
1140
+ method: "POST",
1141
+ headers: {},
1142
+ // Teams posts the JSON again for every delivery; the adapter mutates it.
1143
+ body: JSON.parse(JSON.stringify(activity)),
1144
+ } as unknown as ExpressRequest;
1145
+ const res: RecordingResponse = new RecordingResponse();
1146
+
1147
+ await MicrosoftTeamsUtil.processBotActivity(
1148
+ req,
1149
+ res as unknown as ExpressResponse,
1150
+ );
1151
+
1152
+ return {
1153
+ httpStatus: res.statusCode,
1154
+ httpBody: res.body,
1155
+ calls: connector.requests.slice(firstRequestIndex).map(summarizeCall),
1156
+ };
1157
+ }
1158
+
1159
+ // Teams tries a delivery again when the bot failed it or never answered it.
1160
+ function isFailedDelivery(delivery: DeliveryRecord): boolean {
1161
+ return delivery.httpStatus === null || delivery.httpStatus >= 500;
1162
+ }
1163
+
1164
+ /*
1165
+ * Delivers an activity and, when the bot answers 5xx or not at all, delivers
1166
+ * the SAME activity again (see the header). One redelivery is enough to show
1167
+ * a doubled reply.
1168
+ */
1169
+ async function deliverLikeTeams(
1170
+ activity: JSONObject,
1171
+ maxRedeliveries: number = 1,
1172
+ ): Promise<Array<DeliveryRecord>> {
1173
+ const deliveries: Array<DeliveryRecord> = [await deliverOnce(activity)];
1174
+
1175
+ while (
1176
+ isFailedDelivery(deliveries[deliveries.length - 1]!) &&
1177
+ deliveries.length <= maxRedeliveries
1178
+ ) {
1179
+ deliveries.push(await deliverOnce(activity));
1180
+ }
1181
+
1182
+ return deliveries;
1183
+ }
1184
+
1185
+ // What Teams and the user got, over every delivery of one activity.
1186
+ interface TeamsView {
1187
+ /*
1188
+ * The bot's HTTP answer to each delivery (null: none); more than one entry
1189
+ * means Teams delivered the activity again.
1190
+ */
1191
+ httpStatuses: Array<number | null>;
1192
+ // What the user sees, in order: every message the connector accepted.
1193
+ bubbles: Array<ConnectorCall>;
1194
+ // Messages the connector refused.
1195
+ refused: Array<ConnectorCall>;
1196
+ deletes: Array<ConnectorCall>;
1197
+ }
1198
+
1199
+ function viewOf(deliveries: Array<DeliveryRecord>): TeamsView {
1200
+ const calls: Array<ConnectorCall> = [];
1201
+ for (const delivery of deliveries) {
1202
+ calls.push(...delivery.calls);
1203
+ }
1204
+
1205
+ return {
1206
+ httpStatuses: deliveries.map((delivery: DeliveryRecord) => {
1207
+ return delivery.httpStatus;
1208
+ }),
1209
+ bubbles: calls.filter((call: ConnectorCall) => {
1210
+ return call.method === "POST" && call.accepted;
1211
+ }),
1212
+ refused: calls.filter((call: ConnectorCall) => {
1213
+ return call.method === "POST" && !call.accepted;
1214
+ }),
1215
+ deletes: calls.filter((call: ConnectorCall) => {
1216
+ return call.method === "DELETE";
1217
+ }),
1218
+ };
1219
+ }
1220
+
1221
+ function labelsOf(calls: Array<ConnectorCall>): Array<string> {
1222
+ return calls.map((call: ConnectorCall) => {
1223
+ return call.label;
1224
+ });
1225
+ }
1226
+
1227
+ function cardOf(call: ConnectorCall | undefined): JSONObject {
1228
+ if (!call || !call.card) {
1229
+ throw new Error(
1230
+ `Expected an adaptive card, got ${call?.kind || "nothing"}`,
1231
+ );
1232
+ }
1233
+ return call.card;
1234
+ }
1235
+
1236
+ function textOf(call: ConnectorCall | undefined): string {
1237
+ if (!call || call.text === null) {
1238
+ throw new Error(`Expected a text message, got ${call?.kind || "nothing"}`);
1239
+ }
1240
+ return call.text;
1241
+ }
1242
+
1243
+ // ---- Reading a card ----
1244
+
1245
+ function getBodyElements(card: JSONObject): Array<JSONObject> {
1246
+ return asObjectArray(card["body"]);
1247
+ }
1248
+
1249
+ function getTextBlocks(card: JSONObject): Array<string> {
1250
+ return getBodyElements(card)
1251
+ .filter((element: JSONObject) => {
1252
+ return element["type"] === "TextBlock";
1253
+ })
1254
+ .map((element: JSONObject) => {
1255
+ return String(element["text"]);
1256
+ });
1257
+ }
1258
+
1259
+ function getElement(card: JSONObject, id: string): JSONObject | undefined {
1260
+ return getBodyElements(card).find((element: JSONObject) => {
1261
+ return element["id"] === id;
1262
+ });
1263
+ }
1264
+
1265
+ function getChoices(card: JSONObject, id: string): Array<JSONObject> {
1266
+ return asObjectArray(getElement(card, id)?.["choices"]);
1267
+ }
1268
+
1269
+ function getChoiceValues(card: JSONObject, id: string): Array<string> {
1270
+ return getChoices(card, id).map((choice: JSONObject) => {
1271
+ return String(choice["value"]);
1272
+ });
1273
+ }
1274
+
1275
+ function getActions(card: JSONObject): Array<JSONObject> {
1276
+ return asObjectArray(card["actions"]);
1277
+ }
1278
+
1279
+ function getSubmitData(card: JSONObject): JSONObject {
1280
+ return asObject(
1281
+ getActions(card).find((action: JSONObject) => {
1282
+ return action["type"] === "Action.Submit";
1283
+ })?.["data"],
1284
+ );
1285
+ }
1286
+
1287
+ function getCreateInOneUptimeActions(card: JSONObject): Array<JSONObject> {
1288
+ return getActions(card).filter((action: JSONObject) => {
1289
+ return action["type"] === "Action.OpenUrl";
1290
+ });
1291
+ }
1292
+
1293
+ function dashboardLink(route: string): string {
1294
+ return `${DASHBOARD_URL}/${projectId.toString()}${route}`;
1295
+ }
1296
+
1297
+ interface CardListExpectation {
1298
+ choiceSetId: string;
1299
+ pluralNoun: string;
1300
+ addLaterHint: string;
1301
+ // Every record the project has, in name order.
1302
+ rows: Array<NamedRow>;
1303
+ }
1304
+
1305
+ /*
1306
+ * The card lists the first N records by name and, when N is short of all of
1307
+ * them, says so under the list. Returns N.
1308
+ */
1309
+ function expectListAccountedFor(
1310
+ card: JSONObject,
1311
+ list: CardListExpectation,
1312
+ ): number {
1313
+ const shownIds: Array<string> = getChoiceValues(card, list.choiceSetId);
1314
+ const shownCount: number = shownIds.length;
1315
+ const totalCount: number = list.rows.length;
1316
+
1317
+ expect(shownIds).toEqual(
1318
+ list.rows.slice(0, shownCount).map((row: NamedRow) => {
1319
+ return row._id;
1320
+ }),
1321
+ );
1322
+
1323
+ for (const choice of getChoices(card, list.choiceSetId)) {
1324
+ expect(String(choice["title"]).length).toBeLessThanOrEqual(
1325
+ MICROSOFT_TEAMS_MAX_CHOICE_TITLE_LENGTH,
1326
+ );
1327
+ }
1328
+
1329
+ let expectedNote: string | null = null;
1330
+ if (shownCount < totalCount) {
1331
+ expectedNote =
1332
+ shownCount === 0
1333
+ ? `This project has ${totalCount} ${list.pluralNoun}, too many to list in Microsoft Teams. ${list.addLaterHint}`
1334
+ : `Showing the first ${shownCount} of ${totalCount} ${list.pluralNoun}, by name. ${list.addLaterHint}`;
1335
+ }
1336
+
1337
+ expect(
1338
+ getTextBlocks(card).filter((text: string) => {
1339
+ return (
1340
+ text.includes(` ${list.pluralNoun},`) &&
1341
+ text.endsWith(list.addLaterHint)
1342
+ );
1343
+ }),
1344
+ ).toEqual(expectedNote ? [expectedNote] : []);
1345
+
1346
+ return shownCount;
1347
+ }
1348
+
1349
+ // ---- The operator log ----
1350
+
1351
+ // One logger.error call, as the operator log got it.
1352
+ interface LoggedErrorCall {
1353
+ message: unknown;
1354
+ attributes: LogAttributes | undefined;
1355
+ }
1356
+
1357
+ /*
1358
+ * MicrosoftTeamsReplies.logFailure: a line that says what failed and why,
1359
+ * then the error object itself with the same attributes, so the log keeps its
1360
+ * stack and class. (A Bot Connector HTTP error gets the line alone.) Checks
1361
+ * both calls, the line at `at` and the error right after it.
1362
+ */
1363
+ function expectFailureLogged(data: {
1364
+ at: number;
1365
+ line: string;
1366
+ error: unknown;
1367
+ attributes: LogAttributes | undefined;
1368
+ }): void {
1369
+ expect(loggedErrorCalls[data.at]).toStrictEqual({
1370
+ message: data.line,
1371
+ attributes: data.attributes,
1372
+ });
1373
+ expect(loggedErrorCalls[data.at + 1]?.message).toBe(data.error);
1374
+ expect(loggedErrorCalls[data.at + 1]?.attributes).toStrictEqual(
1375
+ data.attributes,
1376
+ );
1377
+ }
1378
+
1379
+ // OneUptime does not know its dashboard URL, so no reply can link into it.
1380
+ function withoutDashboardUrl(): void {
1381
+ jest
1382
+ .spyOn(DatabaseConfig, "getDashboardUrl")
1383
+ .mockRejectedValue(new Error("simulated: no dashboard URL configured"));
1384
+ }
1385
+
1386
+ /*
1387
+ * Stops the clock the code under test reads - the test realm's Date - at
1388
+ * `now`, and only that: timers stay real, so HTTP to the fake connector and
1389
+ * the real botbuilder stack (which reads the outer realm's clock) run as
1390
+ * ever. afterEach starts it again.
1391
+ */
1392
+ function stopTheClockAt(now: Date): void {
1393
+ jest.useFakeTimers({
1394
+ now: now,
1395
+ doNotFake: [
1396
+ "hrtime",
1397
+ "nextTick",
1398
+ "performance",
1399
+ "queueMicrotask",
1400
+ "requestAnimationFrame",
1401
+ "cancelAnimationFrame",
1402
+ "requestIdleCallback",
1403
+ "cancelIdleCallback",
1404
+ "setImmediate",
1405
+ "clearImmediate",
1406
+ "setInterval",
1407
+ "clearInterval",
1408
+ "setTimeout",
1409
+ "clearTimeout",
1410
+ ],
1411
+ });
1412
+ }
1413
+
1414
+ // ---- Lifecycle ----
1415
+
1416
+ let connector: FakeBotConnector;
1417
+ let realAdapter: RealAdapterUnderTest;
1418
+ let projectId: ObjectID;
1419
+ let loggedErrors: Array<string> = [];
1420
+ let loggedErrorCalls: Array<LoggedErrorCall> = [];
1421
+ let loggedWarnings: Array<string> = [];
1422
+ let outerConsoleErrorCalls: Array<Array<unknown>> = [];
1423
+ let originalOuterConsoleError: Console["error"];
1424
+
1425
+ beforeAll(async () => {
1426
+ connector = await startFakeBotConnector();
1427
+ });
1428
+
1429
+ afterAll(async () => {
1430
+ await connector.close();
1431
+ });
1432
+
1433
+ beforeEach(() => {
1434
+ connector.requests.splice(0);
1435
+ connector.maxUtf16Bytes = TEAMS_MESSAGE_LIMIT_IN_UTF16_BYTES;
1436
+ connector.deleteStatus = 200;
1437
+ connector.refuse = null;
1438
+
1439
+ realAdapter = installRealCloudAdapter();
1440
+ projectId = ObjectID.generate();
1441
+ stubTenantAndInstallCapture(projectId);
1442
+ stubSenderCheck(projectId);
1443
+ stubDashboardUrl();
1444
+
1445
+ loggedErrors = [];
1446
+ loggedErrorCalls = [];
1447
+ jest
1448
+ .spyOn(logger, "error")
1449
+ .mockImplementation(
1450
+ (message: unknown, attributes?: LogAttributes | undefined): void => {
1451
+ loggedErrors.push(String(message));
1452
+ loggedErrorCalls.push({ message: message, attributes: attributes });
1453
+ },
1454
+ );
1455
+ loggedWarnings = [];
1456
+ jest.spyOn(logger, "warn").mockImplementation((message: unknown) => {
1457
+ loggedWarnings.push(String(message));
1458
+ });
1459
+ // processBotActivity debug-logs whole activities.
1460
+ jest.spyOn(logger, "debug").mockImplementation(() => {
1461
+ return undefined;
1462
+ });
1463
+
1464
+ outerConsoleErrorCalls = [];
1465
+ originalOuterConsoleError = outerConsole.error;
1466
+ outerConsole.error = (...args: Array<unknown>): void => {
1467
+ outerConsoleErrorCalls.push(args);
1468
+ };
1469
+ });
1470
+
1471
+ afterEach(() => {
1472
+ outerConsole.error = originalOuterConsoleError;
1473
+ (MicrosoftTeamsUtil as unknown as CachedAdapterSlot).cachedAdapter = null;
1474
+ jest.useRealTimers();
1475
+ jest.restoreAllMocks();
1476
+ });
1477
+
1478
+ // ---- Tests ----
1479
+
1480
+ describe("harness: MicrosoftTeamsUtil runs on the real botbuilder stack", () => {
1481
+ test("botbuilder is the real package, not the moduleNameMapper stub, and processBotActivity uses the injected adapter", () => {
1482
+ expect(typeof CloudAdapter.prototype.process).toBe("function");
1483
+ expect(typeof TeamsActivityHandler.prototype.run).toBe("function");
1484
+ expect(typeof TeamsInfo.getPagedMembers).toBe("function");
1485
+ expect(
1486
+ (MicrosoftTeamsUtil as unknown as CachedAdapterSlot).cachedAdapter,
1487
+ ).toBe(realAdapter.adapter);
1488
+ });
1489
+
1490
+ test("the budgets this file checks against are the ones the code builds to: cards of 40 KiB, then 20 KiB, then no lists; texts of 40 KiB", () => {
1491
+ expect(MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES).toEqual([
1492
+ CARD_BUDGET_IN_UTF16_BYTES,
1493
+ 20 * 1024,
1494
+ 0,
1495
+ ]);
1496
+ expect(MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES).toBe(40 * 1024);
1497
+ });
1498
+
1499
+ test("Teams' 413 reaches the bot as a RestError whose fields the production code reads", async () => {
1500
+ stubProjectData({ monitors: 800 });
1501
+ // Low enough that Teams refuses the first card.
1502
+ connector.maxUtf16Bytes = 30 * 1024;
1503
+
1504
+ await deliverOnce(
1505
+ buildMessageActivity({
1506
+ id: nextActivityId("rest-error-shape"),
1507
+ text: "create incident",
1508
+ }),
1509
+ );
1510
+
1511
+ expect(realAdapter.sendActivitiesErrors).toHaveLength(1);
1512
+ const error: unknown = realAdapter.sendActivitiesErrors[0];
1513
+ const fields: {
1514
+ name?: unknown;
1515
+ message?: unknown;
1516
+ statusCode?: unknown;
1517
+ code?: unknown;
1518
+ details?: unknown;
1519
+ response?: { status?: unknown } | undefined;
1520
+ } = error as {
1521
+ name?: unknown;
1522
+ message?: unknown;
1523
+ statusCode?: unknown;
1524
+ code?: unknown;
1525
+ details?: unknown;
1526
+ response?: { status?: unknown } | undefined;
1527
+ };
1528
+
1529
+ expect(fields.name).toBe("RestError");
1530
+ expect(fields.message).toBe("Message size too large.");
1531
+ expect(fields.statusCode).toBe(413);
1532
+ expect(fields.code).toBe("MessageSizeTooBig");
1533
+ // @azure/core-client puts the parsed error body on details.
1534
+ expect(fields.details).toEqual(MESSAGE_SIZE_TOO_BIG_BODY);
1535
+ expect(fields.response?.status).toBe(413);
1536
+
1537
+ // A native Error of Node's realm: instanceof this realm's Error is false.
1538
+ expect(util.types.isNativeError(error)).toBe(true);
1539
+ expect(error instanceof Error).toBe(false);
1540
+
1541
+ // What the production code reads off it.
1542
+ expect(MicrosoftTeamsMessageSize.isMessageTooLargeError(error)).toBe(true);
1543
+ expect(MicrosoftTeamsMessageSize.getErrorStatusCode(error)).toBe(413);
1544
+ expect(MicrosoftTeamsMessageSize.getErrorCode(error)).toBe(
1545
+ "MessageSizeTooBig",
1546
+ );
1547
+ expect(MicrosoftTeamsReplies.describeError(error)).toBe(
1548
+ "RestError 413 MessageSizeTooBig: Message size too large.",
1549
+ );
1550
+ });
1551
+ });
1552
+
1553
+ describe("'create incident' (#4111): one form Teams accepts, one answer, HTTP 200", () => {
1554
+ test("800 monitors: one 'Create New Incident' card within 40 KiB that names what it left off, HTTP 200, no redelivery", async () => {
1555
+ const project: ProjectData = stubProjectData({ monitors: 800 });
1556
+ const activityId: string = nextActivityId("create-incident-800");
1557
+
1558
+ const view: TeamsView = viewOf(
1559
+ await deliverLikeTeams(
1560
+ buildMessageActivity({ id: activityId, text: "create incident" }),
1561
+ ),
1562
+ );
1563
+
1564
+ // master: [500, 500] and two "Sorry" bubbles.
1565
+ expect(view.httpStatuses).toEqual([200]);
1566
+ expect(view.refused).toEqual([]);
1567
+ expect(labelsOf(view.bubbles)).toEqual(["Create New Incident"]);
1568
+
1569
+ const form: ConnectorCall = view.bubbles[0]!;
1570
+ expect(form.kind).toBe("card");
1571
+ // Threaded under the user's message.
1572
+ expect(form.conversationId).toBe(PERSONAL_CONVERSATION_ID);
1573
+ expect(form.activityIdInPath).toBe(activityId);
1574
+ // The whole message, envelope included, is within 40 KiB.
1575
+ expect(form.wireUtf16Bytes).toBeLessThanOrEqual(40 * 1024);
1576
+ expect(form.cardUtf16Bytes).toBeLessThanOrEqual(CARD_BUDGET_IN_UTF16_BYTES);
1577
+
1578
+ const card: JSONObject = cardOf(form);
1579
+ const shownMonitors: number = expectListAccountedFor(card, {
1580
+ choiceSetId: "incidentMonitors",
1581
+ pluralNoun: "monitors",
1582
+ addLaterHint: INCIDENT_ADD_LATER_HINT,
1583
+ rows: project.monitors,
1584
+ });
1585
+ expect(shownMonitors).toBeGreaterThan(0);
1586
+ expect(shownMonitors).toBeLessThan(800);
1587
+ expect(shownMonitors).toBeLessThanOrEqual(
1588
+ MICROSOFT_TEAMS_MAX_MONITOR_CHOICES,
1589
+ );
1590
+ expect(getTextBlocks(card)).toContain(
1591
+ `Showing the first ${shownMonitors} of 800 monitors, by name. ${INCIDENT_ADD_LATER_HINT}`,
1592
+ );
1593
+
1594
+ // The shorter lists fit whole, so they carry no note.
1595
+ expectListAccountedFor(card, {
1596
+ choiceSetId: "labels",
1597
+ pluralNoun: "labels",
1598
+ addLaterHint: INCIDENT_ADD_LATER_HINT,
1599
+ rows: project.labels,
1600
+ });
1601
+ expect(getChoiceValues(card, "labels")).toHaveLength(20);
1602
+ expectListAccountedFor(card, {
1603
+ choiceSetId: "onCallDutyPolicies",
1604
+ pluralNoun: "on-call policies",
1605
+ addLaterHint: INCIDENT_ADD_LATER_HINT,
1606
+ rows: project.onCallPolicies,
1607
+ });
1608
+ expect(getChoiceValues(card, "onCallDutyPolicies")).toHaveLength(10);
1609
+ // Severity is required, so it is always listed whole.
1610
+ expect(getChoiceValues(card, "incidentSeverity")).toHaveLength(4);
1611
+
1612
+ expect(getSubmitData(card)).toEqual({
1613
+ action: MicrosoftTeamsIncidentActionType.SubmitNewIncident,
1614
+ });
1615
+ expect(getCreateInOneUptimeActions(card)).toEqual([
1616
+ {
1617
+ type: "Action.OpenUrl",
1618
+ title: "Create in OneUptime",
1619
+ url: dashboardLink("/incidents/create"),
1620
+ },
1621
+ ]);
1622
+
1623
+ // Nothing failed on the way, and nothing reached the adapter's 500 path.
1624
+ expect(realAdapter.sendActivitiesErrors).toEqual([]);
1625
+ expect(outerConsoleErrorCalls).toEqual([]);
1626
+ expect(loggedErrors).toEqual([]);
1627
+ });
1628
+
1629
+ test("the same activity delivered twice (Teams redelivers after a slow 200): the repeat posts nothing and is answered 200", async () => {
1630
+ stubProjectData({ monitors: 800 });
1631
+ const activity: JSONObject = buildMessageActivity({
1632
+ id: nextActivityId("redelivered"),
1633
+ text: "create incident",
1634
+ });
1635
+
1636
+ const first: DeliveryRecord = await deliverOnce(activity);
1637
+ const repeat: DeliveryRecord = await deliverOnce(activity);
1638
+
1639
+ expect(first.httpStatus).toBe(200);
1640
+ expect(labelsOf(first.calls)).toEqual(["Create New Incident"]);
1641
+ expect(repeat.httpStatus).toBe(200);
1642
+ expect(repeat.calls).toEqual([]);
1643
+ // Dropped before any work: the project's monitors were read once.
1644
+ expect(MonitorService.findBy).toHaveBeenCalledTimes(1);
1645
+
1646
+ // A new message in the same chat is still answered.
1647
+ const next: DeliveryRecord = await deliverOnce(
1648
+ buildMessageActivity({
1649
+ id: nextActivityId("after-redelivery"),
1650
+ text: "create incident",
1651
+ }),
1652
+ );
1653
+ expect(next.httpStatus).toBe(200);
1654
+ expect(labelsOf(next.calls)).toEqual(["Create New Incident"]);
1655
+ });
1656
+
1657
+ test("Teams redelivers while the first delivery is still being handled (its retry of a slow turn): the redelivery is answered 200 at once and posts nothing, and the user gets one card", async () => {
1658
+ const project: ProjectData = stubProjectData({ monitors: 800 });
1659
+ const activity: JSONObject = buildMessageActivity({
1660
+ id: nextActivityId("slow-first-delivery"),
1661
+ text: "create incident",
1662
+ });
1663
+
1664
+ /*
1665
+ * The first read of the monitors waits until the redelivery has been
1666
+ * answered, as a slow database would. Later reads go straight through, so
1667
+ * a redelivery that is not dropped posts a card of its own rather than
1668
+ * waiting here for ever.
1669
+ */
1670
+ let isFirstReadStarted: boolean = false;
1671
+ let signalFirstReadStarted: () => void = (): void => {
1672
+ return undefined;
1673
+ };
1674
+ const firstReadStarted: Promise<void> = new Promise<void>(
1675
+ (resolve: () => void) => {
1676
+ signalFirstReadStarted = resolve;
1677
+ },
1678
+ );
1679
+ let releaseFirstRead: () => void = (): void => {
1680
+ return undefined;
1681
+ };
1682
+ const firstReadReleased: Promise<void> = new Promise<void>(
1683
+ (resolve: () => void) => {
1684
+ releaseFirstRead = resolve;
1685
+ },
1686
+ );
1687
+ jest
1688
+ .spyOn(MonitorService, "findBy")
1689
+ .mockImplementation(async (findBy: unknown): Promise<Array<Monitor>> => {
1690
+ if (!isFirstReadStarted) {
1691
+ isFirstReadStarted = true;
1692
+ signalFirstReadStarted();
1693
+ await firstReadReleased;
1694
+ }
1695
+ return firstRowsFor(
1696
+ project.monitors,
1697
+ findBy,
1698
+ ) as unknown as Array<Monitor>;
1699
+ });
1700
+
1701
+ const firstDelivery: Promise<DeliveryRecord> = deliverOnce(activity);
1702
+ // Fails at once, instead of hanging, if the first delivery never reads the list.
1703
+ const failUnlessFirstReadStarted: (error?: unknown) => void = (
1704
+ error?: unknown,
1705
+ ): void => {
1706
+ if (!isFirstReadStarted) {
1707
+ throw error || new Error("The first delivery never read the monitors");
1708
+ }
1709
+ };
1710
+ await Promise.race([
1711
+ firstReadStarted,
1712
+ firstDelivery.then(
1713
+ (): void => {
1714
+ return failUnlessFirstReadStarted();
1715
+ },
1716
+ (error: unknown): void => {
1717
+ return failUnlessFirstReadStarted(error);
1718
+ },
1719
+ ),
1720
+ ]);
1721
+
1722
+ const redelivery: DeliveryRecord = await deliverOnce(activity);
1723
+ releaseFirstRead();
1724
+ const first: DeliveryRecord = await firstDelivery;
1725
+
1726
+ // Dropped on arrival: answered while the first delivery was held up.
1727
+ expect(redelivery.httpStatus).toBe(200);
1728
+ expect(redelivery.calls).toEqual([]);
1729
+ /*
1730
+ * The first delivery's window spans the redelivery's, so it would show a
1731
+ * card the redelivery posted too; it shows exactly one.
1732
+ */
1733
+ expect(first.httpStatus).toBe(200);
1734
+ expect(labelsOf(first.calls)).toEqual(["Create New Incident"]);
1735
+ expect(MonitorService.findBy).toHaveBeenCalledTimes(1);
1736
+ expect(loggedErrors).toEqual([]);
1737
+ expect(outerConsoleErrorCalls).toEqual([]);
1738
+ });
1739
+
1740
+ test("the same activity id in another chat is another message (Teams numbers messages per chat): both are answered", async () => {
1741
+ stubProjectData({ monitors: 5 });
1742
+ const activityId: string = nextActivityId("same-id-two-chats");
1743
+
1744
+ const inPersonalChat: DeliveryRecord = await deliverOnce(
1745
+ buildMessageActivity({ id: activityId, text: "create incident" }),
1746
+ );
1747
+ const inGroupChat: DeliveryRecord = await deliverOnce(
1748
+ buildMessageActivity({
1749
+ id: activityId,
1750
+ text: "create incident",
1751
+ conversation: "groupChat",
1752
+ }),
1753
+ );
1754
+
1755
+ expect([inPersonalChat.httpStatus, inGroupChat.httpStatus]).toEqual([
1756
+ 200, 200,
1757
+ ]);
1758
+ expect(
1759
+ [...inPersonalChat.calls, ...inGroupChat.calls].map(
1760
+ (call: ConnectorCall) => {
1761
+ return [call.conversationId, call.label];
1762
+ },
1763
+ ),
1764
+ ).toEqual([
1765
+ [PERSONAL_CONVERSATION_ID, "Create New Incident"],
1766
+ [GROUP_CONVERSATION_ID, "Create New Incident"],
1767
+ ]);
1768
+ });
1769
+
1770
+ test("Teams refuses the 40 KiB card (limit lowered to 30 KiB): the connector sees 413 then a smaller card it accepts, and the user sees one card", async () => {
1771
+ const project: ProjectData = stubProjectData({ monitors: 800 });
1772
+ connector.maxUtf16Bytes = 30 * 1024;
1773
+
1774
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
1775
+ buildMessageActivity({
1776
+ id: nextActivityId("create-incident-30k"),
1777
+ text: "create incident",
1778
+ }),
1779
+ );
1780
+ const view: TeamsView = viewOf(deliveries);
1781
+ const calls: Array<ConnectorCall> = deliveries[0]!.calls;
1782
+
1783
+ expect(view.httpStatuses).toEqual([200]);
1784
+ expect(
1785
+ calls.map((call: ConnectorCall) => {
1786
+ return [call.kind, call.label, call.connectorStatus];
1787
+ }),
1788
+ ).toEqual([
1789
+ ["card", "Create New Incident", 413],
1790
+ ["card", "Create New Incident", 200],
1791
+ ]);
1792
+
1793
+ const refused: ConnectorCall = calls[0]!;
1794
+ const accepted: ConnectorCall = calls[1]!;
1795
+ expect(refused.wireUtf16Bytes).toBeGreaterThan(30 * 1024);
1796
+ expect(refused.cardUtf16Bytes).toBeLessThanOrEqual(
1797
+ CARD_BUDGET_IN_UTF16_BYTES,
1798
+ );
1799
+ expect(accepted.wireUtf16Bytes).toBeLessThanOrEqual(30 * 1024);
1800
+ // Built for the second budget, 20 KiB.
1801
+ expect(accepted.cardUtf16Bytes).toBeLessThanOrEqual(20 * 1024);
1802
+ expect(view.bubbles).toEqual([accepted]);
1803
+
1804
+ // The smaller card lists fewer monitors, and still says what it left off.
1805
+ const shownInRefused: number = getChoiceValues(
1806
+ cardOf(refused),
1807
+ "incidentMonitors",
1808
+ ).length;
1809
+ const shownInAccepted: number = expectListAccountedFor(cardOf(accepted), {
1810
+ choiceSetId: "incidentMonitors",
1811
+ pluralNoun: "monitors",
1812
+ addLaterHint: INCIDENT_ADD_LATER_HINT,
1813
+ rows: project.monitors,
1814
+ });
1815
+ expect(shownInAccepted).toBeGreaterThan(0);
1816
+ expect(shownInAccepted).toBeLessThan(shownInRefused);
1817
+ expect(getCreateInOneUptimeActions(cardOf(accepted))).toEqual([
1818
+ {
1819
+ type: "Action.OpenUrl",
1820
+ title: "Create in OneUptime",
1821
+ url: dashboardLink("/incidents/create"),
1822
+ },
1823
+ ]);
1824
+
1825
+ // The refusal is a warning for the operator, not an error for the user.
1826
+ expect(loggedWarnings).toEqual([
1827
+ `Microsoft Teams refused a ${refused.cardUtf16Bytes} byte card as too large (budget ${CARD_BUDGET_IN_UTF16_BYTES} bytes); trying a smaller one.`,
1828
+ ]);
1829
+ expect(loggedErrors).toEqual([]);
1830
+ expect(outerConsoleErrorCalls).toEqual([]);
1831
+ });
1832
+
1833
+ test("Teams refuses every card (limit 2 KiB): one text says the form was refused as too large and links to creating it in OneUptime, HTTP 200", async () => {
1834
+ stubProjectData({ monitors: 800 });
1835
+ connector.maxUtf16Bytes = 2 * 1024;
1836
+
1837
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
1838
+ buildMessageActivity({
1839
+ id: nextActivityId("create-incident-2k"),
1840
+ text: "create incident",
1841
+ }),
1842
+ );
1843
+ const view: TeamsView = viewOf(deliveries);
1844
+ const calls: Array<ConnectorCall> = deliveries[0]!.calls;
1845
+
1846
+ expect(view.httpStatuses).toEqual([200]);
1847
+ // One card per budget (40 KiB, 20 KiB, no lists), then the explanation.
1848
+ expect(
1849
+ calls.map((call: ConnectorCall) => {
1850
+ return [call.kind, call.connectorStatus];
1851
+ }),
1852
+ ).toEqual([
1853
+ ["card", 413],
1854
+ ["card", 413],
1855
+ ["card", 413],
1856
+ ["text", 200],
1857
+ ]);
1858
+
1859
+ const withoutLists: JSONObject = cardOf(calls[2]);
1860
+ expect(getElement(withoutLists, "incidentMonitors")).toBeUndefined();
1861
+ expect(getElement(withoutLists, "labels")).toBeUndefined();
1862
+ expect(getElement(withoutLists, "onCallDutyPolicies")).toBeUndefined();
1863
+
1864
+ expect(view.bubbles).toHaveLength(1);
1865
+ const reply: string = textOf(view.bubbles[0]);
1866
+ expect(reply).toMatch(
1867
+ /^Sorry, I couldn't open the form to create an incident: Microsoft Teams refused it as too large/,
1868
+ );
1869
+ expect(reply).toContain(dashboardLink("/incidents/create"));
1870
+
1871
+ /*
1872
+ * A Bot Connector HTTP error is logged as the one line: its status and
1873
+ * code say it all, and the object carries the whole request and response.
1874
+ */
1875
+ expect(loggedErrorCalls).toStrictEqual([
1876
+ {
1877
+ message:
1878
+ "Microsoft Teams did not accept the form to create an incident: RestError 413 MessageSizeTooBig: Message size too large.",
1879
+ attributes: { projectId: projectId.toString() },
1880
+ },
1881
+ ]);
1882
+ expect(outerConsoleErrorCalls).toEqual([]);
1883
+ });
1884
+
1885
+ test("Teams refuses the explanation too (limit 512 bytes): still one delivery answered HTTP 200, and the operator log says why the user saw nothing", async () => {
1886
+ stubProjectData({ monitors: 800 });
1887
+ connector.maxUtf16Bytes = 512;
1888
+
1889
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
1890
+ buildMessageActivity({
1891
+ id: nextActivityId("create-incident-512"),
1892
+ text: "create incident",
1893
+ }),
1894
+ );
1895
+ const view: TeamsView = viewOf(deliveries);
1896
+
1897
+ /*
1898
+ * master: the handler's catch replied "Sorry..." and rethrew; whether or
1899
+ * not that reply got through, the adapter answered 500 and Teams
1900
+ * delivered the message again.
1901
+ */
1902
+ expect(view.httpStatuses).toEqual([200]);
1903
+ expect(
1904
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
1905
+ return [call.kind, call.connectorStatus];
1906
+ }),
1907
+ ).toEqual([
1908
+ ["card", 413],
1909
+ ["card", 413],
1910
+ ["card", 413],
1911
+ ["text", 413],
1912
+ ]);
1913
+ expect(view.bubbles).toEqual([]);
1914
+ expect(loggedErrors).toEqual([
1915
+ "Microsoft Teams did not accept the form to create an incident: RestError 413 MessageSizeTooBig: Message size too large.",
1916
+ "Could not send a Microsoft Teams reply: RestError 413 MessageSizeTooBig: Message size too large.",
1917
+ ]);
1918
+ expect(outerConsoleErrorCalls).toEqual([]);
1919
+ });
1920
+
1921
+ test("Teams refuses the form for a reason other than its size (400 BadArgument): no smaller card is tried, and one text names Teams' status and code and links to creating it in OneUptime, HTTP 200", async () => {
1922
+ stubProjectData({ monitors: 800 });
1923
+ connector.refuse = (request: ConnectorRequest): ConnectorAnswer | null => {
1924
+ return isCardPost(request) ? CARD_REFUSED_ANSWER : null;
1925
+ };
1926
+
1927
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
1928
+ buildMessageActivity({
1929
+ id: nextActivityId("create-incident-400"),
1930
+ text: "create incident",
1931
+ }),
1932
+ );
1933
+ const view: TeamsView = viewOf(deliveries);
1934
+
1935
+ expect(view.httpStatuses).toEqual([200]);
1936
+ // Only a refusal for size is worth a smaller card; this one is final.
1937
+ expect(
1938
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
1939
+ return [call.kind, call.connectorStatus];
1940
+ }),
1941
+ ).toEqual([
1942
+ ["card", 400],
1943
+ ["text", 200],
1944
+ ]);
1945
+ // The real RestError's status and code, in the user's words.
1946
+ expect(labelsOf(view.bubbles)).toEqual([
1947
+ `Sorry, I couldn't open the form to create an incident: Microsoft Teams did not accept it (400 BadArgument). You can create it in OneUptime instead: ${dashboardLink("/incidents/create")}`,
1948
+ ]);
1949
+ expect(loggedWarnings).toEqual([]);
1950
+ // One line, as for any Bot Connector HTTP error.
1951
+ expect(loggedErrorCalls).toStrictEqual([
1952
+ {
1953
+ message:
1954
+ "Microsoft Teams did not accept the form to create an incident: RestError 400 BadArgument: The card was refused.",
1955
+ attributes: { projectId: projectId.toString() },
1956
+ },
1957
+ ]);
1958
+ expect(outerConsoleErrorCalls).toEqual([]);
1959
+ });
1960
+
1961
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
1962
+ type ListsUnreadableCase = {
1963
+ command: string;
1964
+ what: string;
1965
+ createRoute: string;
1966
+ // Whether OneUptime knows its dashboard URL, to link to the create page.
1967
+ hasDashboardUrl: boolean;
1968
+ };
1969
+
1970
+ test.each<ListsUnreadableCase>([
1971
+ {
1972
+ command: "create incident",
1973
+ what: "an incident",
1974
+ createRoute: "/incidents/create",
1975
+ hasDashboardUrl: true,
1976
+ },
1977
+ {
1978
+ command: "create maintenance",
1979
+ what: "a scheduled maintenance event",
1980
+ createRoute: "/scheduled-maintenance-events/create",
1981
+ hasDashboardUrl: true,
1982
+ },
1983
+ {
1984
+ command: "create incident",
1985
+ what: "an incident",
1986
+ createRoute: "/incidents/create",
1987
+ hasDashboardUrl: false,
1988
+ },
1989
+ ])(
1990
+ "'$command' when the project's monitors cannot be read (dashboard URL known: $hasDashboardUrl): one text that says so and where else to create it, HTTP 200, no redelivery",
1991
+ async (unreadable: ListsUnreadableCase) => {
1992
+ stubProjectData({ monitors: 5 });
1993
+ const databaseFailure: Error = new Error("simulated database failure");
1994
+ jest.spyOn(MonitorService, "findBy").mockRejectedValue(databaseFailure);
1995
+ if (!unreadable.hasDashboardUrl) {
1996
+ withoutDashboardUrl();
1997
+ }
1998
+
1999
+ const view: TeamsView = viewOf(
2000
+ await deliverLikeTeams(
2001
+ buildMessageActivity({
2002
+ id: nextActivityId("lists-unreadable"),
2003
+ text: unreadable.command,
2004
+ }),
2005
+ ),
2006
+ );
2007
+
2008
+ // master: [500, 500] and two "Sorry" bubbles.
2009
+ expect(view.httpStatuses).toEqual([200]);
2010
+ expect(view.refused).toEqual([]);
2011
+ expect(labelsOf(view.bubbles)).toEqual([
2012
+ `Sorry, I couldn't open the form to create ${unreadable.what} because OneUptime could not load the lists it needs just now. Please try again in a minute, or create it in OneUptime instead${
2013
+ unreadable.hasDashboardUrl
2014
+ ? `: ${dashboardLink(unreadable.createRoute)}`
2015
+ : "."
2016
+ }`,
2017
+ ]);
2018
+ // What failed, then the error itself.
2019
+ expect(loggedErrors).toEqual([
2020
+ `Could not load the Microsoft Teams form to create ${unreadable.what}: Error: simulated database failure`,
2021
+ "Error: simulated database failure",
2022
+ ]);
2023
+ expectFailureLogged({
2024
+ at: 0,
2025
+ line: loggedErrors[0]!,
2026
+ error: databaseFailure,
2027
+ attributes: { projectId: projectId.toString() },
2028
+ });
2029
+ expect(outerConsoleErrorCalls).toEqual([]);
2030
+ },
2031
+ );
2032
+
2033
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
2034
+ type NoSeveritiesCase = {
2035
+ hasDashboardUrl: boolean;
2036
+ };
2037
+
2038
+ test.each<NoSeveritiesCase>([
2039
+ { hasDashboardUrl: true },
2040
+ { hasDashboardUrl: false },
2041
+ ])(
2042
+ "a project without incident severities (dashboard URL known: $hasDashboardUrl): one text that says where to add one, no form",
2043
+ async (noSeverities: NoSeveritiesCase) => {
2044
+ stubProjectData({ monitors: 5, severities: 0 });
2045
+ if (!noSeverities.hasDashboardUrl) {
2046
+ withoutDashboardUrl();
2047
+ }
2048
+
2049
+ const view: TeamsView = viewOf(
2050
+ await deliverLikeTeams(
2051
+ buildMessageActivity({
2052
+ id: nextActivityId("no-severities"),
2053
+ text: "create incident",
2054
+ }),
2055
+ ),
2056
+ );
2057
+
2058
+ expect(view.httpStatuses).toEqual([200]);
2059
+ expect(view.refused).toEqual([]);
2060
+ /*
2061
+ * Where the dashboard has the page, linked when it can be:
2062
+ * /dashboard/<project>/incidents/settings/severity.
2063
+ */
2064
+ expect(labelsOf(view.bubbles)).toEqual([
2065
+ `An incident needs a severity, and this project has no incident severities yet. Add one in OneUptime under ${
2066
+ noSeverities.hasDashboardUrl
2067
+ ? `[Incidents → Settings → Incident Severity](${dashboardLink("/incidents/settings/severity")})`
2068
+ : "Incidents → Settings → Incident Severity"
2069
+ }, then try again.`,
2070
+ ]);
2071
+ expect(loggedErrors).toEqual([]);
2072
+ },
2073
+ );
2074
+
2075
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
2076
+ type NotLinkedCase = {
2077
+ command: string;
2078
+ // How the reply starts: why the sender needs a connected account.
2079
+ purpose: string;
2080
+ };
2081
+
2082
+ test.each<NotLinkedCase>([
2083
+ {
2084
+ command: "create incident",
2085
+ purpose: "To create incidents from Microsoft Teams",
2086
+ },
2087
+ {
2088
+ command: "create maintenance",
2089
+ purpose: "To schedule maintenance from Microsoft Teams",
2090
+ },
2091
+ ])(
2092
+ "'$command' from a Teams user with no connected account: one reply pointing at User Settings → Microsoft Teams, no form",
2093
+ async (notLinked: NotLinkedCase) => {
2094
+ stubProjectData({ monitors: 800 });
2095
+ /*
2096
+ * The real account lookup runs, and finds no link: only the table under
2097
+ * it is stubbed, so it is Auth.ts that says the account is not linked.
2098
+ */
2099
+ jest
2100
+ .spyOn(MicrosoftTeamsAuthAction, "getOneUptimeUserIdFromTeamsUserId")
2101
+ .mockRestore();
2102
+ jest
2103
+ .spyOn(WorkspaceUserAuthTokenService, "findOneBy")
2104
+ .mockResolvedValue(null);
2105
+
2106
+ const view: TeamsView = viewOf(
2107
+ await deliverLikeTeams(
2108
+ buildMessageActivity({
2109
+ id: nextActivityId("not-linked"),
2110
+ text: notLinked.command,
2111
+ }),
2112
+ ),
2113
+ );
2114
+
2115
+ expect(view.httpStatuses).toEqual([200]);
2116
+ expect(view.refused).toEqual([]);
2117
+ expect(view.bubbles).toHaveLength(1);
2118
+ const reply: string = textOf(view.bubbles[0]);
2119
+ expect(reply.startsWith(notLinked.purpose)).toBe(true);
2120
+ expect(reply).toContain("User Settings → Microsoft Teams");
2121
+ expect(reply).toContain(
2122
+ dashboardLink("/user-settings/microsoft-teams-integration"),
2123
+ );
2124
+ // The link looked for was this sender's, in this project.
2125
+ expect(WorkspaceUserAuthTokenService.findOneBy).toHaveBeenCalledTimes(1);
2126
+ expect(WorkspaceUserAuthTokenService.findOneBy).toHaveBeenCalledWith(
2127
+ expect.objectContaining({
2128
+ query: expect.objectContaining({
2129
+ workspaceUserId: SENDER_AAD_OBJECT_ID,
2130
+ projectId: projectId,
2131
+ }),
2132
+ }),
2133
+ );
2134
+ // Told before filling the form in: the lists were never read.
2135
+ expect(MonitorService.findBy).not.toHaveBeenCalled();
2136
+ // Expected, not a server error.
2137
+ expect(loggedErrors).toEqual([]);
2138
+ },
2139
+ );
2140
+ });
2141
+
2142
+ describe("'create maintenance' (#4111)", () => {
2143
+ test("in a group chat (@mention), 800 monitors: one card for everyone there, that names the sender's time zone and carries it in its submit data", async () => {
2144
+ const project: ProjectData = stubProjectData({ monitors: 800 });
2145
+ const activityId: string = nextActivityId("create-maintenance-group");
2146
+
2147
+ const view: TeamsView = viewOf(
2148
+ await deliverLikeTeams(
2149
+ buildMessageActivity({
2150
+ id: activityId,
2151
+ text: "create maintenance Database Upgrade Window",
2152
+ conversation: "groupChat",
2153
+ localTimezone: SENDER_TIMEZONE,
2154
+ }),
2155
+ ),
2156
+ );
2157
+
2158
+ // master: [500, 500] and two "Sorry" bubbles.
2159
+ expect(view.httpStatuses).toEqual([200]);
2160
+ expect(view.refused).toEqual([]);
2161
+ expect(labelsOf(view.bubbles)).toEqual([
2162
+ "Create New Scheduled Maintenance",
2163
+ ]);
2164
+
2165
+ const form: ConnectorCall = view.bubbles[0]!;
2166
+ expect(form.conversationId).toBe(GROUP_CONVERSATION_ID);
2167
+ expect(form.activityIdInPath).toBe(activityId);
2168
+ expect(form.wireUtf16Bytes).toBeLessThanOrEqual(40 * 1024);
2169
+ expect(form.cardUtf16Bytes).toBeLessThanOrEqual(CARD_BUDGET_IN_UTF16_BYTES);
2170
+
2171
+ const card: JSONObject = cardOf(form);
2172
+ /*
2173
+ * The zone the start and end are typed in, named and sent back on submit.
2174
+ * Not "your time zone": whoever fills the form in may be somewhere else.
2175
+ */
2176
+ expect(getTextBlocks(card)).toContain(
2177
+ `Start and end times are in ${SENDER_TIMEZONE}.`,
2178
+ );
2179
+ expect(getSubmitData(card)).toEqual({
2180
+ action:
2181
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
2182
+ timezone: SENDER_TIMEZONE,
2183
+ });
2184
+ // The words after the command, as typed (the @mention stripped).
2185
+ expect(getElement(card, "scheduledMaintenanceTitle")?.["value"]).toBe(
2186
+ "Database Upgrade Window",
2187
+ );
2188
+
2189
+ const shownMonitors: number = expectListAccountedFor(card, {
2190
+ choiceSetId: "scheduledMaintenanceMonitors",
2191
+ pluralNoun: "monitors",
2192
+ addLaterHint: MAINTENANCE_ADD_LATER_HINT,
2193
+ rows: project.monitors,
2194
+ });
2195
+ expect(shownMonitors).toBeGreaterThan(0);
2196
+ expect(shownMonitors).toBeLessThan(800);
2197
+ expect(getCreateInOneUptimeActions(card)).toEqual([
2198
+ {
2199
+ type: "Action.OpenUrl",
2200
+ title: "Create in OneUptime",
2201
+ url: dashboardLink("/scheduled-maintenance-events/create"),
2202
+ },
2203
+ ]);
2204
+
2205
+ /*
2206
+ * Anyone in the chat may submit it, and the submit checks whoever does,
2207
+ * so the one who asked is not checked first.
2208
+ */
2209
+ expect(
2210
+ MicrosoftTeamsAuthAction.getOneUptimeUserIdFromTeamsUserId,
2211
+ ).not.toHaveBeenCalled();
2212
+ expect(
2213
+ WorkspaceActionAuthorization.getProjectMemberProps,
2214
+ ).not.toHaveBeenCalled();
2215
+ expect(WorkspaceActionAuthorization.assertCanCreate).not.toHaveBeenCalled();
2216
+ expect(outerConsoleErrorCalls).toEqual([]);
2217
+ expect(loggedErrors).toEqual([]);
2218
+ });
2219
+
2220
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
2221
+ type ZoneSourceCase = {
2222
+ description: string;
2223
+ localTimezone: string | undefined;
2224
+ clientInfoTimezone: string | undefined;
2225
+ // The zone the card names and carries in its submit data; null for none.
2226
+ expectedTimezone: string | null;
2227
+ };
2228
+
2229
+ /*
2230
+ * Each place Teams names the sender's zone, as the real adapter hands the
2231
+ * activity over: a field it dropped would leave the form without a zone.
2232
+ */
2233
+ test.each<ZoneSourceCase>([
2234
+ {
2235
+ description:
2236
+ "the activity's localTimezone, over a different clientInfo zone",
2237
+ localTimezone: SENDER_TIMEZONE,
2238
+ clientInfoTimezone: "Europe/Berlin",
2239
+ expectedTimezone: SENDER_TIMEZONE,
2240
+ },
2241
+ {
2242
+ description: "the clientInfo entity's zone, when the activity names none",
2243
+ localTimezone: undefined,
2244
+ clientInfoTimezone: "Asia/Kolkata",
2245
+ expectedTimezone: "Asia/Kolkata",
2246
+ },
2247
+ {
2248
+ description:
2249
+ "no zone anywhere (iOS has been seen to send none): the card says so and carries none",
2250
+ localTimezone: undefined,
2251
+ clientInfoTimezone: undefined,
2252
+ expectedTimezone: null,
2253
+ },
2254
+ ])(
2255
+ "the zone on the form comes from $description",
2256
+ async (zoneCase: ZoneSourceCase) => {
2257
+ stubProjectData({ monitors: 5 });
2258
+
2259
+ const view: TeamsView = viewOf(
2260
+ await deliverLikeTeams(
2261
+ buildMessageActivity({
2262
+ id: nextActivityId("maintenance-zone"),
2263
+ text: "create maintenance",
2264
+ localTimezone: zoneCase.localTimezone,
2265
+ clientInfoTimezone: zoneCase.clientInfoTimezone,
2266
+ }),
2267
+ ),
2268
+ );
2269
+
2270
+ expect(view.httpStatuses).toEqual([200]);
2271
+ expect(labelsOf(view.bubbles)).toEqual([
2272
+ "Create New Scheduled Maintenance",
2273
+ ]);
2274
+ const card: JSONObject = cardOf(view.bubbles[0]);
2275
+ expect(getTextBlocks(card)).toContain(
2276
+ zoneCase.expectedTimezone
2277
+ ? `Start and end times are in ${zoneCase.expectedTimezone}.`
2278
+ : "Start and end times are in the time zone Microsoft Teams reports for you, or in UTC if it does not report one.",
2279
+ );
2280
+ expect(getSubmitData(card)).toEqual({
2281
+ action:
2282
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
2283
+ ...(zoneCase.expectedTimezone
2284
+ ? { timezone: zoneCase.expectedTimezone }
2285
+ : {}),
2286
+ });
2287
+ },
2288
+ );
2289
+ });
2290
+
2291
+ describe("who is checked before the form (#4111 review): the sender of a personal chat; in a channel or group chat nobody, as the submit checks whoever submits", () => {
2292
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
2293
+ type SharedChatCase = {
2294
+ command: string;
2295
+ heading: string;
2296
+ conversation: ConversationKind;
2297
+ conversationId: string;
2298
+ };
2299
+
2300
+ test.each<SharedChatCase>([
2301
+ {
2302
+ command: "create incident",
2303
+ heading: "Create New Incident",
2304
+ conversation: "groupChat",
2305
+ conversationId: GROUP_CONVERSATION_ID,
2306
+ },
2307
+ {
2308
+ command: "create incident",
2309
+ heading: "Create New Incident",
2310
+ conversation: "channel",
2311
+ conversationId: CHANNEL_CONVERSATION_ID,
2312
+ },
2313
+ {
2314
+ command: "create maintenance",
2315
+ heading: "Create New Scheduled Maintenance",
2316
+ conversation: "groupChat",
2317
+ conversationId: GROUP_CONVERSATION_ID,
2318
+ },
2319
+ {
2320
+ command: "create maintenance",
2321
+ heading: "Create New Scheduled Maintenance",
2322
+ conversation: "channel",
2323
+ conversationId: CHANNEL_CONVERSATION_ID,
2324
+ },
2325
+ ])(
2326
+ "'$command' in a $conversation, from a sender every check would refuse: the form is posted for everyone there, and nobody is looked up",
2327
+ async (sharedChat: SharedChatCase) => {
2328
+ stubProjectData({ monitors: 5 });
2329
+ // Each check would refuse this sender, were it asked.
2330
+ jest
2331
+ .spyOn(MicrosoftTeamsAuthAction, "getOneUptimeUserIdFromTeamsUserId")
2332
+ .mockRejectedValue(
2333
+ new MicrosoftTeamsAccountNotLinkedException(
2334
+ "No OneUptime user linked to this Microsoft Teams user. Please authenticate with Microsoft Teams.",
2335
+ ),
2336
+ );
2337
+ jest
2338
+ .spyOn(WorkspaceActionAuthorization, "getProjectMemberProps")
2339
+ .mockRejectedValue(
2340
+ new NotAuthorizedException(
2341
+ WorkspaceActionAuthorization.NOT_A_PROJECT_MEMBER_MESSAGE,
2342
+ ),
2343
+ );
2344
+ jest
2345
+ .spyOn(WorkspaceActionAuthorization, "assertCanCreate")
2346
+ .mockRejectedValue(
2347
+ new NotAuthorizedException(
2348
+ "You do not have permission to create a scheduled maintenance event.",
2349
+ ),
2350
+ );
2351
+ const activityId: string = nextActivityId("shared-chat-form");
2352
+
2353
+ const view: TeamsView = viewOf(
2354
+ await deliverLikeTeams(
2355
+ buildMessageActivity({
2356
+ id: activityId,
2357
+ text: sharedChat.command,
2358
+ conversation: sharedChat.conversation,
2359
+ }),
2360
+ ),
2361
+ );
2362
+
2363
+ expect(view.httpStatuses).toEqual([200]);
2364
+ expect(view.refused).toEqual([]);
2365
+ expect(
2366
+ view.bubbles.map((call: ConnectorCall) => {
2367
+ return [call.kind, call.label, call.conversationId];
2368
+ }),
2369
+ ).toEqual([["card", sharedChat.heading, sharedChat.conversationId]]);
2370
+ expect(view.bubbles[0]!.activityIdInPath).toBe(activityId);
2371
+ expect(
2372
+ MicrosoftTeamsAuthAction.getOneUptimeUserIdFromTeamsUserId,
2373
+ ).not.toHaveBeenCalled();
2374
+ expect(
2375
+ WorkspaceActionAuthorization.getProjectMemberProps,
2376
+ ).not.toHaveBeenCalled();
2377
+ expect(
2378
+ WorkspaceActionAuthorization.assertCanCreate,
2379
+ ).not.toHaveBeenCalled();
2380
+ expect(loggedErrors).toEqual([]);
2381
+ },
2382
+ );
2383
+
2384
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
2385
+ type PersonalChatCommandCase = {
2386
+ command: string;
2387
+ };
2388
+
2389
+ test.each<PersonalChatCommandCase>([
2390
+ { command: "create incident" },
2391
+ { command: "create maintenance" },
2392
+ ])(
2393
+ "'$command' in a personal chat from a Teams user who is no longer a member of the project: told so by the real membership check, once, before the form; the lists are never read",
2394
+ async (personal: PersonalChatCommandCase) => {
2395
+ stubProjectData({ monitors: 800 });
2396
+ useRealMembershipCheck(null);
2397
+
2398
+ const view: TeamsView = viewOf(
2399
+ await deliverLikeTeams(
2400
+ buildMessageActivity({
2401
+ id: nextActivityId("personal-not-member"),
2402
+ text: personal.command,
2403
+ }),
2404
+ ),
2405
+ );
2406
+
2407
+ expect(view.httpStatuses).toEqual([200]);
2408
+ expect(labelsOf(view.bubbles)).toEqual([
2409
+ "Your OneUptime account is not a member of this project. Ask a project admin to invite you, then try again.",
2410
+ ]);
2411
+ // The one checked is the sender, in this project, from the database.
2412
+ expect(TeamMemberService.findBy).toHaveBeenCalledTimes(1);
2413
+ expect(TeamMemberService.findBy).toHaveBeenCalledWith(
2414
+ expect.objectContaining({
2415
+ query: {
2416
+ userId: ONEUPTIME_USER_ID,
2417
+ projectId: projectId,
2418
+ hasAcceptedInvitation: true,
2419
+ },
2420
+ }),
2421
+ );
2422
+ expect(MonitorService.findBy).not.toHaveBeenCalled();
2423
+ // A refusal, not a server error.
2424
+ expect(loggedErrors).toEqual([]);
2425
+ },
2426
+ );
2427
+
2428
+ test("'create maintenance' in a personal chat from a member who may not create scheduled maintenance (a read-only Viewer): told why by the real permission check, once, before the form", async () => {
2429
+ stubProjectData({ monitors: 5 });
2430
+ useRealMembershipCheck([Permission.Viewer]);
2431
+
2432
+ const view: TeamsView = viewOf(
2433
+ await deliverLikeTeams(
2434
+ buildMessageActivity({
2435
+ id: nextActivityId("personal-no-permission"),
2436
+ text: "create maintenance",
2437
+ }),
2438
+ ),
2439
+ );
2440
+
2441
+ expect(view.httpStatuses).toEqual([200]);
2442
+ // The same refusal a submit would get (see the maintenance submit tests).
2443
+ expect(labelsOf(view.bubbles)).toEqual([
2444
+ "You do not have permission to create a scheduled maintenance event. You do not have permissions to create Scheduled Maintenance Event. You need one of these permissions: Project Owner, Project Admin, Project Member, Scheduled Maintenance Admin, Scheduled Maintenance Member, Create Scheduled Maintenance",
2445
+ ]);
2446
+ expect(WorkspaceActionAuthorization.assertCanCreate).toHaveBeenCalledTimes(
2447
+ 1,
2448
+ );
2449
+ expect(WorkspaceActionAuthorization.assertCanCreate).toHaveBeenCalledWith(
2450
+ expect.objectContaining({
2451
+ modelType: ScheduledMaintenance,
2452
+ action: "create a scheduled maintenance event",
2453
+ }),
2454
+ );
2455
+ expect(MonitorService.findBy).not.toHaveBeenCalled();
2456
+ expect(loggedErrors).toEqual([]);
2457
+ });
2458
+
2459
+ test("'create incident' in a personal chat asks for membership only (deliberate, see the incident submit tests): a read-only Viewer gets the form", async () => {
2460
+ stubProjectData({ monitors: 5 });
2461
+ useRealMembershipCheck([Permission.Viewer]);
2462
+
2463
+ const view: TeamsView = viewOf(
2464
+ await deliverLikeTeams(
2465
+ buildMessageActivity({
2466
+ id: nextActivityId("personal-incident-membership-only"),
2467
+ text: "create incident",
2468
+ }),
2469
+ ),
2470
+ );
2471
+
2472
+ expect(view.httpStatuses).toEqual([200]);
2473
+ expect(labelsOf(view.bubbles)).toEqual(["Create New Incident"]);
2474
+ expect(TeamMemberService.findBy).toHaveBeenCalledTimes(1);
2475
+ expect(WorkspaceActionAuthorization.assertCanCreate).not.toHaveBeenCalled();
2476
+ expect(loggedErrors).toEqual([]);
2477
+ });
2478
+
2479
+ test("the sender of a personal chat cannot be checked (the membership read fails): one reply says to try again, the log says why, HTTP 200", async () => {
2480
+ stubProjectData({ monitors: 5 });
2481
+ const membershipFailure: Error = new Error(
2482
+ "simulated membership read failure",
2483
+ );
2484
+ jest
2485
+ .spyOn(WorkspaceActionAuthorization, "getProjectMemberProps")
2486
+ .mockRejectedValue(membershipFailure);
2487
+
2488
+ const view: TeamsView = viewOf(
2489
+ await deliverLikeTeams(
2490
+ buildMessageActivity({
2491
+ id: nextActivityId("personal-check-fails"),
2492
+ text: "create incident",
2493
+ }),
2494
+ ),
2495
+ );
2496
+
2497
+ expect(view.httpStatuses).toEqual([200]);
2498
+ expect(labelsOf(view.bubbles)).toEqual([
2499
+ "Sorry, I couldn't open the form to create an incident because OneUptime could not check your account just now. Please try again in a minute.",
2500
+ ]);
2501
+ expect(MonitorService.findBy).not.toHaveBeenCalled();
2502
+ expect(loggedErrors).toEqual([
2503
+ "Could not check the Microsoft Teams sender of a create command: Error: simulated membership read failure",
2504
+ "Error: simulated membership read failure",
2505
+ ]);
2506
+ expectFailureLogged({
2507
+ at: 0,
2508
+ line: loggedErrors[0]!,
2509
+ error: membershipFailure,
2510
+ attributes: { projectId: projectId.toString() },
2511
+ });
2512
+ });
2513
+ });
2514
+
2515
+ describe("a title or a name past the form's limits (#4111 review): cut before an emoji, never through it", () => {
2516
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
2517
+ type TitleCutCase = {
2518
+ command: string;
2519
+ titleInputId: string;
2520
+ monitorsChoiceSetId: string;
2521
+ // The title column's length, and the title input's maxLength.
2522
+ titleMaxLength: number;
2523
+ };
2524
+
2525
+ test.each<TitleCutCase>([
2526
+ {
2527
+ command: "create incident",
2528
+ titleInputId: "incidentTitle",
2529
+ monitorsChoiceSetId: "incidentMonitors",
2530
+ titleMaxLength: 500,
2531
+ },
2532
+ {
2533
+ command: "create maintenance",
2534
+ titleInputId: "scheduledMaintenanceTitle",
2535
+ monitorsChoiceSetId: "scheduledMaintenanceMonitors",
2536
+ titleMaxLength: 100,
2537
+ },
2538
+ ])(
2539
+ "'$command <title>' with an emoji across the $titleMaxLength-character cut, and a monitor named with one across the choice cut: both end before the emoji",
2540
+ async (cut: TitleCutCase) => {
2541
+ const project: ProjectData = stubProjectData({ monitors: 3 });
2542
+ /*
2543
+ * A choice title over MICROSOFT_TEAMS_MAX_CHOICE_TITLE_LENGTH keeps one
2544
+ * place less than that for its text, then "…"; the emoji straddles the
2545
+ * cut.
2546
+ */
2547
+ const beforeChoiceCut: string = "a".repeat(
2548
+ MICROSOFT_TEAMS_MAX_CHOICE_TITLE_LENGTH - 2,
2549
+ );
2550
+ project.monitors[0]!.name = `${beforeChoiceCut}🔥 checkout, payments`;
2551
+ const beforeTitleCut: string = "b".repeat(cut.titleMaxLength - 1);
2552
+
2553
+ const view: TeamsView = viewOf(
2554
+ await deliverLikeTeams(
2555
+ buildMessageActivity({
2556
+ id: nextActivityId("emoji-cut"),
2557
+ text: `${cut.command} ${beforeTitleCut}🔧 and the rest`,
2558
+ }),
2559
+ ),
2560
+ );
2561
+
2562
+ expect(view.httpStatuses).toEqual([200]);
2563
+ expect(view.bubbles).toHaveLength(1);
2564
+ const card: JSONObject = cardOf(view.bubbles[0]);
2565
+ /*
2566
+ * Half an emoji is a lone UTF-16 surrogate, which Teams shows as "�"
2567
+ * and a database write turns into U+FFFD. The whole emoji goes.
2568
+ */
2569
+ expect(getElement(card, cut.titleInputId)?.["value"]).toBe(
2570
+ beforeTitleCut,
2571
+ );
2572
+ expect(getElement(card, cut.titleInputId)?.["maxLength"]).toBe(
2573
+ cut.titleMaxLength,
2574
+ );
2575
+ expect(getChoices(card, cut.monitorsChoiceSetId)[0]).toEqual({
2576
+ title: `${beforeChoiceCut}…`,
2577
+ value: project.monitors[0]!._id,
2578
+ });
2579
+ },
2580
+ );
2581
+ });
2582
+
2583
+ describe("questions for the AI assistant (#4111 review): one answer, and the question stays out of the error log", () => {
2584
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
2585
+ type QuestionCase = {
2586
+ description: string;
2587
+ text: string;
2588
+ // How the error log names the message, since it must not quote it.
2589
+ label: string;
2590
+ };
2591
+
2592
+ const QUESTIONS: Array<QuestionCase> = [
2593
+ {
2594
+ description: "'ask <question>'",
2595
+ text: "ask which monitors are down in eu-west-1?",
2596
+ label: '"ask"',
2597
+ },
2598
+ {
2599
+ description: "a question in plain words",
2600
+ text: "Which monitors are down in eu-west-1?",
2601
+ label: "a 37-character question",
2602
+ },
2603
+ ];
2604
+
2605
+ test.each<QuestionCase>(QUESTIONS)(
2606
+ "$description from a Teams user with no connected account: one reply that says where to connect it, with the link; nothing logged as an error",
2607
+ async (question: QuestionCase) => {
2608
+ // The real account lookup runs, and finds no link.
2609
+ jest
2610
+ .spyOn(MicrosoftTeamsAuthAction, "getOneUptimeUserIdFromTeamsUserId")
2611
+ .mockRestore();
2612
+ jest
2613
+ .spyOn(WorkspaceUserAuthTokenService, "findOneBy")
2614
+ .mockResolvedValue(null);
2615
+
2616
+ const view: TeamsView = viewOf(
2617
+ await deliverLikeTeams(
2618
+ buildMessageActivity({
2619
+ id: nextActivityId("question-not-linked"),
2620
+ text: question.text,
2621
+ }),
2622
+ ),
2623
+ );
2624
+
2625
+ expect(view.httpStatuses).toEqual([200]);
2626
+ expect(labelsOf(view.bubbles)).toEqual([
2627
+ `To ask OneUptime questions from Microsoft Teams, first connect your Microsoft Teams account to OneUptime in [OneUptime → User Settings → Microsoft Teams](${dashboardLink("/user-settings/microsoft-teams-integration")}), then try again.`,
2628
+ ]);
2629
+ expect(loggedErrors).toEqual([]);
2630
+ },
2631
+ );
2632
+
2633
+ test.each<QuestionCase>(QUESTIONS)(
2634
+ "$description whose account lookup fails: the one generic reply with its reference, not 'connect your account'; the log names the message as $label",
2635
+ async (question: QuestionCase) => {
2636
+ const lookupFailure: Error = new Error(
2637
+ "simulated account lookup failure",
2638
+ );
2639
+ jest
2640
+ .spyOn(MicrosoftTeamsAuthAction, "getOneUptimeUserIdFromTeamsUserId")
2641
+ .mockRejectedValue(lookupFailure);
2642
+ const activityId: string = nextActivityId("question-lookup-fails");
2643
+ const activity: JSONObject = buildMessageActivity({
2644
+ id: activityId,
2645
+ text: question.text,
2646
+ });
2647
+
2648
+ const view: TeamsView = viewOf(await deliverLikeTeams(activity));
2649
+
2650
+ /*
2651
+ * Before, any failed lookup was answered as a missing account, which
2652
+ * told a linked user to connect an account they had connected. It is an
2653
+ * unexpected failure like any other.
2654
+ */
2655
+ expect(view.httpStatuses).toEqual([200]);
2656
+ expect(labelsOf(view.bubbles)).toEqual([
2657
+ MicrosoftTeamsUtil.getUnexpectedErrorMessage(activity),
2658
+ ]);
2659
+ expect(loggedErrors).toEqual([
2660
+ `Microsoft Teams message ${activityId} (${question.label}) failed: Error: simulated account lookup failure`,
2661
+ "Error: simulated account lookup failure",
2662
+ ]);
2663
+ expectFailureLogged({
2664
+ at: 0,
2665
+ line: loggedErrors[0]!,
2666
+ error: lookupFailure,
2667
+ attributes: { projectId: projectId.toString() },
2668
+ });
2669
+ // What was asked is for the debug log only.
2670
+ for (const line of loggedErrors) {
2671
+ expect(line).not.toContain("eu-west-1");
2672
+ }
2673
+ expect(outerConsoleErrorCalls).toEqual([]);
2674
+ },
2675
+ );
2676
+ });
2677
+
2678
+ describe("controls: commands that always worked still answer once with HTTP 200", () => {
2679
+ test("'help'", async () => {
2680
+ const view: TeamsView = viewOf(
2681
+ await deliverLikeTeams(
2682
+ buildMessageActivity({ id: nextActivityId("help"), text: "help" }),
2683
+ ),
2684
+ );
2685
+
2686
+ expect(view.httpStatuses).toEqual([200]);
2687
+ expect(view.bubbles).toHaveLength(1);
2688
+ const reply: string = textOf(view.bubbles[0]);
2689
+ expect(reply).toMatch(/^Hello! I'm the OneUptime bot/);
2690
+ // Both create commands take the form's title after them.
2691
+ expect(reply).toContain(
2692
+ "\n- **create incident [title]** - Create a new incident\n- **create maintenance [title]** - Create a new scheduled maintenance event\n",
2693
+ );
2694
+ });
2695
+
2696
+ test("'show scheduled maintenance' (the command the customer saw working)", async () => {
2697
+ jest
2698
+ .spyOn(ScheduledMaintenanceService, "findBy")
2699
+ .mockResolvedValue([] as Array<ScheduledMaintenance>);
2700
+
2701
+ const view: TeamsView = viewOf(
2702
+ await deliverLikeTeams(
2703
+ buildMessageActivity({
2704
+ id: nextActivityId("show-maintenance"),
2705
+ text: "show scheduled maintenance",
2706
+ }),
2707
+ ),
2708
+ );
2709
+
2710
+ expect(view.httpStatuses).toEqual([200]);
2711
+ expect(view.bubbles).toHaveLength(1);
2712
+ expect(textOf(view.bubbles[0])).toContain(
2713
+ "**Scheduled Maintenance Events**",
2714
+ );
2715
+ });
2716
+
2717
+ test("'show scheduled maintenance' with 10 events of 800 monitors each: one reply within the text budget that names 10 monitors per event", async () => {
2718
+ const monitors: Array<Monitor> = namedRows(800, (index: number) => {
2719
+ return `Production HTTP Monitor ${padded(index, 6)}`;
2720
+ }) as unknown as Array<Monitor>;
2721
+ const events: Array<ScheduledMaintenance> = [];
2722
+ for (let index: number = 1; index <= 10; index++) {
2723
+ events.push({
2724
+ id: ObjectID.generate(),
2725
+ title: `Maintenance window ${index}`,
2726
+ description: "Rolling restart of every production monitor host.",
2727
+ startsAt: new Date("2026-10-01T14:00:00.000Z"),
2728
+ endsAt: new Date("2026-10-01T16:00:00.000Z"),
2729
+ currentScheduledMaintenanceState: { name: "Scheduled" },
2730
+ monitors: monitors,
2731
+ scheduledMaintenanceNumber: index,
2732
+ } as unknown as ScheduledMaintenance);
2733
+ }
2734
+ jest.spyOn(ScheduledMaintenanceService, "findBy").mockResolvedValue(events);
2735
+
2736
+ const view: TeamsView = viewOf(
2737
+ await deliverLikeTeams(
2738
+ buildMessageActivity({
2739
+ id: nextActivityId("show-maintenance-800"),
2740
+ text: "show scheduled maintenance",
2741
+ }),
2742
+ ),
2743
+ );
2744
+
2745
+ // Listing all 800 names per event was half a megabyte.
2746
+ expect(view.httpStatuses).toEqual([200]);
2747
+ expect(view.refused).toEqual([]);
2748
+ expect(view.bubbles).toHaveLength(1);
2749
+ const reply: string = textOf(view.bubbles[0]);
2750
+ expect(toUtf16Bytes(reply)).toBeLessThanOrEqual(
2751
+ MICROSOFT_TEAMS_TEXT_MESSAGE_BUDGET_IN_BYTES,
2752
+ );
2753
+ expect(reply.split(" and 790 more").length - 1).toBe(10);
2754
+ expect(reply).toContain(
2755
+ "Production HTTP Monitor 000009, Production HTTP Monitor 000010 and 790 more",
2756
+ );
2757
+ expect(reply).not.toContain("Production HTTP Monitor 000011");
2758
+ });
2759
+
2760
+ test("'show active incidents' at the columns' limits (10 incidents with 500-character titles, 100-character monitor names): one reply cut to the text budget, with a note, accepted at once", async () => {
2761
+ const truncationNote: string =
2762
+ "…\n\n_This reply was shortened to fit in Microsoft Teams. Open OneUptime to see everything._";
2763
+ jest
2764
+ .spyOn(IncidentStateService, "getUnresolvedIncidentStates")
2765
+ .mockResolvedValue([
2766
+ { id: ObjectID.generate() } as unknown as IncidentState,
2767
+ ]);
2768
+ const monitors: Array<Monitor> = namedRows(12, (index: number) => {
2769
+ return toLongName(`Production HTTP Monitor ${padded(index, 6)}`);
2770
+ }) as unknown as Array<Monitor>;
2771
+ const incidents: Array<Incident> = [];
2772
+ for (let index: number = 1; index <= 10; index++) {
2773
+ incidents.push({
2774
+ id: ObjectID.generate(),
2775
+ incidentNumber: index,
2776
+ // An incident title may be 500 characters long.
2777
+ title: (
2778
+ `Incident ${index}: ` +
2779
+ "Checkout API returns 502 for card payments in every region. ".repeat(
2780
+ 10,
2781
+ )
2782
+ ).substring(0, 500),
2783
+ description: "Customers cannot pay. ".repeat(20),
2784
+ currentIncidentState: { name: "Investigating" },
2785
+ incidentSeverity: { name: "Critical" },
2786
+ declaredAt: new Date("2026-09-29T09:00:00.000Z"),
2787
+ monitors: monitors,
2788
+ } as unknown as Incident);
2789
+ }
2790
+ jest.spyOn(IncidentService, "findBy").mockResolvedValue(incidents);
2791
+
2792
+ const view: TeamsView = viewOf(
2793
+ await deliverLikeTeams(
2794
+ buildMessageActivity({
2795
+ id: nextActivityId("show-incidents-long"),
2796
+ text: "show active incidents",
2797
+ }),
2798
+ ),
2799
+ );
2800
+
2801
+ expect(view.httpStatuses).toEqual([200]);
2802
+ expect(view.refused).toEqual([]);
2803
+ expect(view.bubbles).toHaveLength(1);
2804
+ const reply: string = textOf(view.bubbles[0]);
2805
+ // Whole, the reply is about 43,000 bytes, over the budget; Teams got it cut.
2806
+ expect(toUtf16Bytes(reply)).toBeLessThanOrEqual(40 * 1024);
2807
+ expect(reply.startsWith("**Active Incidents** (10)\n\n")).toBe(true);
2808
+ expect(reply.endsWith(truncationNote)).toBe(true);
2809
+ const shownIncidents: number =
2810
+ reply.split("• [Open in Dashboard](").length - 1;
2811
+ expect(shownIncidents).toBeGreaterThan(0);
2812
+ expect(shownIncidents).toBeLessThan(10);
2813
+ });
2814
+ });
2815
+
2816
+ describe("never a 500: whatever fails, Teams gets HTTP 200 and the user one answer", () => {
2817
+ test("tenant resolution throws: one reply that gives the activity id as a reference, HTTP 200 (master: 500 and no reply at all)", async () => {
2818
+ const outage: Error = new Error("simulated database outage");
2819
+ jest
2820
+ .spyOn(MicrosoftTeamsUtil, "resolveProjectByTenantId")
2821
+ .mockRejectedValue(outage);
2822
+ const activityId: string = nextActivityId("tenant-unresolved");
2823
+ const activity: JSONObject = buildMessageActivity({
2824
+ id: activityId,
2825
+ text: "create incident",
2826
+ });
2827
+
2828
+ const view: TeamsView = viewOf(await deliverLikeTeams(activity));
2829
+
2830
+ expect(view.httpStatuses).toEqual([200]);
2831
+ expect(view.bubbles).toHaveLength(1);
2832
+ const reply: string = textOf(view.bubbles[0]);
2833
+ expect(reply).toBe(MicrosoftTeamsUtil.getUnexpectedErrorMessage(activity));
2834
+ expect(reply).toContain(`reference ${activityId}`);
2835
+ /*
2836
+ * The server log carries the same reference, so it can be found. Logged
2837
+ * by the message handler itself (it names the command): the failure was
2838
+ * handled where it happened, not left to escape to the last-resort guard
2839
+ * in processBotActivity, which logs it differently. The line, then the
2840
+ * error itself, which keeps its stack.
2841
+ */
2842
+ expect(loggedErrors).toEqual([
2843
+ `Microsoft Teams message ${activityId} ("create incident") failed: Error: simulated database outage`,
2844
+ "Error: simulated database outage",
2845
+ ]);
2846
+ // The project was never resolved, so the log cannot name one.
2847
+ expectFailureLogged({
2848
+ at: 0,
2849
+ line: loggedErrors[0]!,
2850
+ error: outage,
2851
+ attributes: { projectId: undefined },
2852
+ });
2853
+ expect(outerConsoleErrorCalls).toEqual([]);
2854
+ });
2855
+
2856
+ test("a message handler that throws before replying: one generic reply, HTTP 200", async () => {
2857
+ const handlerBug: Error = new Error("simulated handler bug");
2858
+ jest
2859
+ .spyOn(MicrosoftTeamsUtil, "handleBotMessageActivity")
2860
+ .mockRejectedValue(handlerBug);
2861
+ const activityId: string = nextActivityId("handler-throws");
2862
+ const activity: JSONObject = buildMessageActivity({
2863
+ id: activityId,
2864
+ text: "create incident",
2865
+ });
2866
+
2867
+ const view: TeamsView = viewOf(await deliverLikeTeams(activity));
2868
+
2869
+ expect(view.httpStatuses).toEqual([200]);
2870
+ expect(
2871
+ view.bubbles.map((call: ConnectorCall) => {
2872
+ return call.text;
2873
+ }),
2874
+ ).toEqual([MicrosoftTeamsUtil.getUnexpectedErrorMessage(activity)]);
2875
+ expect(loggedErrors).toEqual([
2876
+ `Microsoft Teams message activity ${activityId} failed: Error: simulated handler bug`,
2877
+ "Error: simulated handler bug",
2878
+ ]);
2879
+ // The error as the handler threw it, through the real botbuilder run.
2880
+ expectFailureLogged({
2881
+ at: 0,
2882
+ line: loggedErrors[0]!,
2883
+ error: handlerBug,
2884
+ attributes: undefined,
2885
+ });
2886
+ expect(outerConsoleErrorCalls).toEqual([]);
2887
+ });
2888
+
2889
+ test("a message handler that throws, and Teams refuses the apology too: still HTTP 200, after a single attempt", async () => {
2890
+ jest
2891
+ .spyOn(MicrosoftTeamsUtil, "handleBotMessageActivity")
2892
+ .mockRejectedValue(new Error("simulated handler bug"));
2893
+ connector.refuse = (): ConnectorAnswer => {
2894
+ return MESSAGE_REFUSED_ANSWER;
2895
+ };
2896
+ const activityId: string = nextActivityId("apology-refused");
2897
+ const activity: JSONObject = buildMessageActivity({
2898
+ id: activityId,
2899
+ text: "create incident",
2900
+ });
2901
+
2902
+ const view: TeamsView = viewOf(await deliverLikeTeams(activity));
2903
+
2904
+ // The last line of defence cannot itself end the turn in a 500.
2905
+ expect(view.httpStatuses).toEqual([200]);
2906
+ expect(view.bubbles).toEqual([]);
2907
+ expect(labelsOf(view.refused)).toEqual([
2908
+ MicrosoftTeamsUtil.getUnexpectedErrorMessage(activity),
2909
+ ]);
2910
+ expect(loggedErrors).toEqual([
2911
+ `Microsoft Teams message activity ${activityId} failed: Error: simulated handler bug`,
2912
+ "Error: simulated handler bug",
2913
+ "Could not send a Microsoft Teams reply: RestError 403 Forbidden: The message was refused.",
2914
+ ]);
2915
+ expect(outerConsoleErrorCalls).toEqual([]);
2916
+ });
2917
+
2918
+ test("a message handler that throws after it replied: no second reply, HTTP 200", async () => {
2919
+ jest
2920
+ .spyOn(MicrosoftTeamsUtil, "handleBotMessageActivity")
2921
+ .mockImplementation(
2922
+ async (data: {
2923
+ activity: JSONObject;
2924
+ turnContext: TurnContext;
2925
+ }): Promise<void> => {
2926
+ await data.turnContext.sendActivity("The only reply.");
2927
+ throw new Error("simulated failure after the reply");
2928
+ },
2929
+ );
2930
+
2931
+ const view: TeamsView = viewOf(
2932
+ await deliverLikeTeams(
2933
+ buildMessageActivity({
2934
+ id: nextActivityId("throws-after-reply"),
2935
+ text: "create incident",
2936
+ }),
2937
+ ),
2938
+ );
2939
+
2940
+ expect(view.httpStatuses).toEqual([200]);
2941
+ expect(labelsOf(view.bubbles)).toEqual(["The only reply."]);
2942
+ expect(outerConsoleErrorCalls).toEqual([]);
2943
+ });
2944
+
2945
+ test("an invoke whose handling throws is answered HTTP 200, not 500 (the error) or 501 (no invoke response)", async () => {
2946
+ const invokeFailure: Error = new Error("simulated invoke failure");
2947
+ jest
2948
+ .spyOn(MicrosoftTeamsUtil, "handleBotInvokeActivity")
2949
+ .mockRejectedValue(invokeFailure);
2950
+ const activityId: string = nextActivityId("invoke-throws");
2951
+
2952
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
2953
+ buildInvokeActivity(activityId),
2954
+ );
2955
+
2956
+ expect(
2957
+ deliveries.map((delivery: DeliveryRecord) => {
2958
+ return delivery.httpStatus;
2959
+ }),
2960
+ ).toEqual([200]);
2961
+ // The invoke response travels back in the HTTP answer; nothing is posted.
2962
+ expect(deliveries[0]!.calls).toEqual([]);
2963
+ expect(MicrosoftTeamsUtil.handleBotInvokeActivity).toHaveBeenCalledTimes(1);
2964
+ expect(loggedErrors).toEqual([
2965
+ `Microsoft Teams invoke activity ${activityId} failed: Error: simulated invoke failure`,
2966
+ "Error: simulated invoke failure",
2967
+ ]);
2968
+ expectFailureLogged({
2969
+ at: 0,
2970
+ line: loggedErrors[0]!,
2971
+ error: invokeFailure,
2972
+ attributes: undefined,
2973
+ });
2974
+ expect(outerConsoleErrorCalls).toEqual([]);
2975
+ });
2976
+
2977
+ test("an invoke action that fails inside the handler: one 'that action failed' reply, HTTP 200", async () => {
2978
+ const lookupFailure: Error = new Error(
2979
+ "simulated membership lookup failure",
2980
+ );
2981
+ jest
2982
+ .spyOn(WorkspaceActionAuthorization, "getProjectMemberProps")
2983
+ .mockRejectedValue(lookupFailure);
2984
+
2985
+ const view: TeamsView = viewOf(
2986
+ await deliverLikeTeams(
2987
+ buildInvokeActivity(nextActivityId("invoke-fails")),
2988
+ ),
2989
+ );
2990
+
2991
+ expect(view.httpStatuses).toEqual([200]);
2992
+ expect(
2993
+ view.bubbles.map((call: ConnectorCall) => {
2994
+ return call.text;
2995
+ }),
2996
+ ).toEqual(["Sorry, that action failed. Please try again later."]);
2997
+ // Answered by the invoke handler itself: nothing escaped to the guard.
2998
+ expect(loggedErrors).toEqual([
2999
+ "Error handling bot invoke activity: Error: simulated membership lookup failure",
3000
+ "Error: simulated membership lookup failure",
3001
+ ]);
3002
+ expectFailureLogged({
3003
+ at: 0,
3004
+ line: loggedErrors[0]!,
3005
+ error: lookupFailure,
3006
+ attributes: {
3007
+ actionType: MicrosoftTeamsIncidentActionType.AckIncident,
3008
+ projectId: projectId.toString(),
3009
+ },
3010
+ });
3011
+ expect(outerConsoleErrorCalls).toEqual([]);
3012
+ });
3013
+
3014
+ test("a submitted form whose action fails, and Teams refuses the apology too: HTTP 200 after a single attempt, and no second apology", async () => {
3015
+ jest
3016
+ .spyOn(WorkspaceActionAuthorization, "getProjectMemberProps")
3017
+ .mockRejectedValue(new Error("simulated membership lookup failure"));
3018
+ connector.refuse = (): ConnectorAnswer => {
3019
+ return MESSAGE_REFUSED_ANSWER;
3020
+ };
3021
+
3022
+ const view: TeamsView = viewOf(
3023
+ await deliverLikeTeams(
3024
+ buildCardSubmitActivity({
3025
+ id: nextActivityId("submit-apology-refused"),
3026
+ replyToId: "msg-incident-form",
3027
+ value: {
3028
+ action: MicrosoftTeamsIncidentActionType.SubmitNewIncident,
3029
+ },
3030
+ }),
3031
+ ),
3032
+ );
3033
+
3034
+ expect(view.httpStatuses).toEqual([200]);
3035
+ expect(view.bubbles).toEqual([]);
3036
+ expect(labelsOf(view.refused)).toEqual([
3037
+ "Sorry, that action failed. Please try again later.",
3038
+ ]);
3039
+ expect(loggedErrors).toEqual([
3040
+ "Error handling bot invoke activity: Error: simulated membership lookup failure",
3041
+ "Error: simulated membership lookup failure",
3042
+ "Could not send a Microsoft Teams reply: RestError 403 Forbidden: The message was refused.",
3043
+ ]);
3044
+ expect(outerConsoleErrorCalls).toEqual([]);
3045
+ });
3046
+
3047
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
3048
+ type BotAddedCase = {
3049
+ type: BotAddedActivityType;
3050
+ handler:
3051
+ | "handleConversationUpdateActivity"
3052
+ | "handleInstallationUpdateActivity";
3053
+ };
3054
+
3055
+ test.each<BotAddedCase>([
3056
+ {
3057
+ type: "conversationUpdate",
3058
+ handler: "handleConversationUpdateActivity",
3059
+ },
3060
+ {
3061
+ type: "installationUpdate",
3062
+ handler: "handleInstallationUpdateActivity",
3063
+ },
3064
+ ])(
3065
+ "the bot added to a chat, announced as $type, and the handler throws: HTTP 200, logged, and nothing posted in a chat nobody wrote in (master: 500, so Teams sent it again)",
3066
+ async (botAdded: BotAddedCase) => {
3067
+ const installFailure: Error = new Error("simulated install failure");
3068
+ jest
3069
+ .spyOn(MicrosoftTeamsUtil, botAdded.handler)
3070
+ .mockRejectedValue(installFailure);
3071
+ const activityId: string = nextActivityId(botAdded.type);
3072
+
3073
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
3074
+ buildBotAddedActivity(botAdded.type, activityId),
3075
+ );
3076
+
3077
+ expect(
3078
+ deliveries.map((delivery: DeliveryRecord) => {
3079
+ return delivery.httpStatus;
3080
+ }),
3081
+ ).toEqual([200]);
3082
+ expect(deliveries[0]!.calls).toEqual([]);
3083
+ expect(MicrosoftTeamsUtil[botAdded.handler]).toHaveBeenCalledTimes(1);
3084
+ expect(loggedErrors).toEqual([
3085
+ `Microsoft Teams ${botAdded.type} activity ${activityId} failed: Error: simulated install failure`,
3086
+ "Error: simulated install failure",
3087
+ ]);
3088
+ expectFailureLogged({
3089
+ at: 0,
3090
+ line: loggedErrors[0]!,
3091
+ error: installFailure,
3092
+ attributes: undefined,
3093
+ });
3094
+ expect(outerConsoleErrorCalls).toEqual([]);
3095
+ },
3096
+ );
3097
+ });
3098
+
3099
+ describe("submitting the form (#4111): confirmed first, then the form is removed, best-effort", () => {
3100
+ const CREATED_INCIDENT_ID: ObjectID = ObjectID.generate();
3101
+
3102
+ interface OpenedIncidentForm {
3103
+ project: ProjectData;
3104
+ formId: string;
3105
+ card: JSONObject;
3106
+ }
3107
+
3108
+ // Asks for the form the way a user does, and returns the card Teams got.
3109
+ async function openIncidentForm(
3110
+ conversation: ConversationKind = "personal",
3111
+ ): Promise<OpenedIncidentForm> {
3112
+ const project: ProjectData = stubProjectData({
3113
+ monitors: 5,
3114
+ labels: 3,
3115
+ onCallPolicies: 2,
3116
+ });
3117
+
3118
+ const view: TeamsView = viewOf(
3119
+ await deliverLikeTeams(
3120
+ buildMessageActivity({
3121
+ id: nextActivityId("open-form"),
3122
+ text: "create incident Checkout API returns 502",
3123
+ conversation: conversation,
3124
+ }),
3125
+ ),
3126
+ );
3127
+
3128
+ expect(labelsOf(view.bubbles)).toEqual(["Create New Incident"]);
3129
+ const form: ConnectorCall = view.bubbles[0]!;
3130
+
3131
+ return {
3132
+ project: project,
3133
+ formId: form.messageId || "",
3134
+ card: cardOf(form),
3135
+ };
3136
+ }
3137
+
3138
+ // What the card's inputs hold after the user filled it in.
3139
+ function filledInIncidentForm(opened: OpenedIncidentForm): JSONObject {
3140
+ return {
3141
+ ...getSubmitData(opened.card),
3142
+ incidentTitle: ` ${String(getElement(opened.card, "incidentTitle")?.["value"])} `,
3143
+ incidentDescription: "Customers cannot pay.",
3144
+ incidentSeverity: getChoiceValues(opened.card, "incidentSeverity")[0]!,
3145
+ incidentMonitors: getChoiceValues(opened.card, "incidentMonitors")
3146
+ .slice(0, 2)
3147
+ .join(","),
3148
+ monitorStatus: getChoiceValues(opened.card, "monitorStatus")[2]!,
3149
+ };
3150
+ }
3151
+
3152
+ function stubIncidentCreate(): void {
3153
+ jest.spyOn(IncidentService, "create").mockImplementation(() => {
3154
+ const created: Incident = new Incident();
3155
+ created.id = CREATED_INCIDENT_ID;
3156
+ return Promise.resolve(created);
3157
+ });
3158
+ jest
3159
+ .spyOn(
3160
+ WorkspaceProjectReferenceValidator,
3161
+ "validateReferencesBelongToProject",
3162
+ )
3163
+ .mockResolvedValue(undefined);
3164
+ }
3165
+
3166
+ function createdIncidentData(): Incident {
3167
+ const createSpy: ReturnType<typeof jest.spyOn> = jest.spyOn(
3168
+ IncidentService,
3169
+ "create",
3170
+ );
3171
+ const firstCall: Array<unknown> | undefined = createSpy.mock.calls[0];
3172
+ return (firstCall?.[0] as { data: Incident }).data;
3173
+ }
3174
+
3175
+ // What the user reads once the incident exists.
3176
+ function incidentCreatedConfirmation(): string {
3177
+ return `✅ Incident created successfully!\n\nView incident: ${dashboardLink(`/incidents/${CREATED_INCIDENT_ID.toString()}`)}`;
3178
+ }
3179
+
3180
+ test("the confirmation is posted, then the form is deleted; the incident has what the form offered and the user chose", async () => {
3181
+ const opened: OpenedIncidentForm = await openIncidentForm();
3182
+ // "create incident <title>" filled the title in, as typed.
3183
+ expect(getElement(opened.card, "incidentTitle")?.["value"]).toBe(
3184
+ "Checkout API returns 502",
3185
+ );
3186
+ expect(opened.formId).not.toBe("");
3187
+ stubIncidentCreate();
3188
+ const submitId: string = nextActivityId("submit-incident");
3189
+ const value: JSONObject = filledInIncidentForm(opened);
3190
+
3191
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
3192
+ buildCardSubmitActivity({
3193
+ id: submitId,
3194
+ replyToId: opened.formId,
3195
+ value: value,
3196
+ }),
3197
+ );
3198
+ const view: TeamsView = viewOf(deliveries);
3199
+
3200
+ expect(value["action"]).toBe(
3201
+ MicrosoftTeamsIncidentActionType.SubmitNewIncident,
3202
+ );
3203
+ expect(view.httpStatuses).toEqual([200]);
3204
+ expect(
3205
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
3206
+ return [call.method, call.label, call.connectorStatus];
3207
+ }),
3208
+ ).toEqual([
3209
+ ["POST", incidentCreatedConfirmation(), 200],
3210
+ ["DELETE", opened.formId, 200],
3211
+ ]);
3212
+ // The confirmation answers the submit; the DELETE removes the form.
3213
+ expect(view.bubbles[0]!.activityIdInPath).toBe(submitId);
3214
+ expect(view.deletes[0]!.conversationId).toBe(PERSONAL_CONVERSATION_ID);
3215
+
3216
+ expect(IncidentService.create).toHaveBeenCalledTimes(1);
3217
+ const incident: Incident = createdIncidentData();
3218
+ expect(incident.title).toBe("Checkout API returns 502");
3219
+ expect(incident.description).toBe("Customers cannot pay.");
3220
+ expect(incident.projectId?.toString()).toBe(projectId.toString());
3221
+ expect(incident.createdByUserId?.toString()).toBe(
3222
+ ONEUPTIME_USER_ID.toString(),
3223
+ );
3224
+ expect(incident.incidentSeverityId?.toString()).toBe(
3225
+ opened.project.severities[0]!._id,
3226
+ );
3227
+ expect(
3228
+ (incident.monitors || []).map((monitor: Monitor) => {
3229
+ return monitor.id?.toString();
3230
+ }),
3231
+ ).toEqual([
3232
+ opened.project.monitors[0]!._id,
3233
+ opened.project.monitors[1]!._id,
3234
+ ]);
3235
+ // The status change goes through the incident, not onto the monitors.
3236
+ expect(incident.changeMonitorStatusToId?.toString()).toBe(
3237
+ opened.project.monitorStatuses[2]!._id,
3238
+ );
3239
+ expect(loggedErrors).toEqual([]);
3240
+ expect(outerConsoleErrorCalls).toEqual([]);
3241
+ });
3242
+
3243
+ test("the form cannot be deleted (404): the user still sees only the confirmation, nothing that says it failed, HTTP 200", async () => {
3244
+ const opened: OpenedIncidentForm = await openIncidentForm();
3245
+ stubIncidentCreate();
3246
+ connector.deleteStatus = 404;
3247
+
3248
+ const view: TeamsView = viewOf(
3249
+ await deliverLikeTeams(
3250
+ buildCardSubmitActivity({
3251
+ id: nextActivityId("submit-delete-404"),
3252
+ replyToId: opened.formId,
3253
+ value: filledInIncidentForm(opened),
3254
+ }),
3255
+ ),
3256
+ );
3257
+
3258
+ expect(view.httpStatuses).toEqual([200]);
3259
+ expect(
3260
+ view.deletes.map((call: ConnectorCall) => {
3261
+ return [call.label, call.connectorStatus];
3262
+ }),
3263
+ ).toEqual([[opened.formId, 404]]);
3264
+ expect(labelsOf(view.bubbles)).toEqual([incidentCreatedConfirmation()]);
3265
+ expect(IncidentService.create).toHaveBeenCalledTimes(1);
3266
+ // Removing the form is a courtesy: its failure is not even an error log.
3267
+ expect(loggedErrors).toEqual([]);
3268
+ expect(outerConsoleErrorCalls).toEqual([]);
3269
+ });
3270
+
3271
+ test("Teams refuses the confirmation (a 403, which the connector does not retry): one incident, nothing says the create failed, HTTP 200", async () => {
3272
+ const opened: OpenedIncidentForm = await openIncidentForm();
3273
+ stubIncidentCreate();
3274
+ connector.refuse = (request: ConnectorRequest): ConnectorAnswer | null => {
3275
+ const text: unknown = request.activity?.["text"];
3276
+ return request.method === "POST" &&
3277
+ typeof text === "string" &&
3278
+ text.startsWith("✅")
3279
+ ? MESSAGE_REFUSED_ANSWER
3280
+ : null;
3281
+ };
3282
+
3283
+ const view: TeamsView = viewOf(
3284
+ await deliverLikeTeams(
3285
+ buildCardSubmitActivity({
3286
+ id: nextActivityId("submit-confirmation-refused"),
3287
+ replyToId: opened.formId,
3288
+ value: filledInIncidentForm(opened),
3289
+ }),
3290
+ ),
3291
+ );
3292
+
3293
+ /*
3294
+ * master: the refusal landed in the create's catch, which answered
3295
+ * "❌ Failed to create incident. Please try again." for an incident that
3296
+ * existed, inviting a second one.
3297
+ */
3298
+ expect(view.httpStatuses).toEqual([200]);
3299
+ expect(IncidentService.create).toHaveBeenCalledTimes(1);
3300
+ // The confirmation is the only message the bot even tried to post.
3301
+ expect(
3302
+ view.refused.map((call: ConnectorCall) => {
3303
+ return [call.label, call.connectorStatus];
3304
+ }),
3305
+ ).toEqual([[incidentCreatedConfirmation(), 403]]);
3306
+ expect(view.bubbles).toEqual([]);
3307
+ // The operator can see why the user got no confirmation.
3308
+ expect(loggedErrors).toEqual([
3309
+ "Could not send a Microsoft Teams reply: RestError 403 Forbidden: The message was refused.",
3310
+ ]);
3311
+ expect(outerConsoleErrorCalls).toEqual([]);
3312
+ });
3313
+
3314
+ test("the same submit delivered twice creates one incident and confirms once", async () => {
3315
+ const opened: OpenedIncidentForm = await openIncidentForm();
3316
+ stubIncidentCreate();
3317
+ const submit: JSONObject = buildCardSubmitActivity({
3318
+ id: nextActivityId("submit-redelivered"),
3319
+ replyToId: opened.formId,
3320
+ value: filledInIncidentForm(opened),
3321
+ });
3322
+
3323
+ const first: DeliveryRecord = await deliverOnce(submit);
3324
+ const repeat: DeliveryRecord = await deliverOnce(submit);
3325
+
3326
+ expect([first.httpStatus, repeat.httpStatus]).toEqual([200, 200]);
3327
+ expect(
3328
+ first.calls.map((call: ConnectorCall) => {
3329
+ return call.method;
3330
+ }),
3331
+ ).toEqual(["POST", "DELETE"]);
3332
+ expect(repeat.calls).toEqual([]);
3333
+ expect(IncidentService.create).toHaveBeenCalledTimes(1);
3334
+ });
3335
+
3336
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
3337
+ type RedisStoryCase = {
3338
+ story: string;
3339
+ // What Redis answers the claim of the first delivery: taken, or down.
3340
+ firstClaim: "taken" | "down";
3341
+ };
3342
+
3343
+ /*
3344
+ * The claim is shared through Redis and remembered in this process too. A
3345
+ * redelivery must be dropped whatever Redis says in between: a Redis that
3346
+ * has no record of the first claim answers "not claimed yet".
3347
+ */
3348
+ test.each<RedisStoryCase>([
3349
+ {
3350
+ story:
3351
+ "Redis was down for the first delivery and is back, with no record of it, for the redelivery",
3352
+ firstClaim: "down",
3353
+ },
3354
+ {
3355
+ story:
3356
+ "Redis took the first claim and lost it (a restart) before the redelivery",
3357
+ firstClaim: "taken",
3358
+ },
3359
+ ])(
3360
+ "the same submit delivered twice while $story: still one incident, and the redelivery posts nothing",
3361
+ async (redis: RedisStoryCase) => {
3362
+ const opened: OpenedIncidentForm = await openIncidentForm();
3363
+ stubIncidentCreate();
3364
+ const claimInRedis: SpyInstance<typeof GlobalCache.setStringIfNotExists> =
3365
+ jest.spyOn(GlobalCache, "setStringIfNotExists");
3366
+ if (redis.firstClaim === "down") {
3367
+ claimInRedis.mockRejectedValueOnce(new Error("Cache is not connected"));
3368
+ } else {
3369
+ claimInRedis.mockResolvedValueOnce(true);
3370
+ }
3371
+ // Asked again for the redelivery, Redis finds no key and sets it.
3372
+ claimInRedis.mockResolvedValueOnce(true);
3373
+ const submit: JSONObject = buildCardSubmitActivity({
3374
+ id: nextActivityId("submit-redis-story"),
3375
+ replyToId: opened.formId,
3376
+ value: filledInIncidentForm(opened),
3377
+ });
3378
+
3379
+ const first: DeliveryRecord = await deliverOnce(submit);
3380
+ const redelivery: DeliveryRecord = await deliverOnce(submit);
3381
+
3382
+ /*
3383
+ * Redis is asked for both, so that other App instances learn of the
3384
+ * claim, but this process's memory decides the redelivery first.
3385
+ */
3386
+ expect(claimInRedis).toHaveBeenCalledTimes(2);
3387
+ // A second incident would page its on-call policies a second time.
3388
+ expect(IncidentService.create).toHaveBeenCalledTimes(1);
3389
+ expect([first.httpStatus, redelivery.httpStatus]).toEqual([200, 200]);
3390
+ expect(
3391
+ first.calls.map((call: ConnectorCall) => {
3392
+ return call.method;
3393
+ }),
3394
+ ).toEqual(["POST", "DELETE"]);
3395
+ expect(redelivery.calls).toEqual([]);
3396
+ /*
3397
+ * No Redis client in this process (as in a test or a script), so there
3398
+ * is no other instance to miss the claim: the fallback is not an error.
3399
+ */
3400
+ expect(loggedErrors).toEqual([]);
3401
+ },
3402
+ );
3403
+
3404
+ test("Redis is configured but down: every message is still answered once and a redelivery still dropped, and the operator hears of the fallback once a minute, not once a message", async () => {
3405
+ stubProjectData({ monitors: 5 });
3406
+ /*
3407
+ * A client whose connection dropped: GlobalCache refuses every claim with
3408
+ * "Cache is not connected", and the dedupe falls back to memory.
3409
+ */
3410
+ jest
3411
+ .spyOn(Redis, "getClient")
3412
+ .mockReturnValue(
3413
+ {} as unknown as NonNullable<ReturnType<typeof Redis.getClient>>,
3414
+ );
3415
+ /*
3416
+ * The clock the once-a-minute throttle reads, stopped a day ahead of any
3417
+ * fallback an earlier test could have logged.
3418
+ */
3419
+ const start: number = Date.now() + 24 * 60 * 60 * 1000;
3420
+ stopTheClockAt(new Date(start));
3421
+ const firstMessage: JSONObject = buildMessageActivity({
3422
+ id: nextActivityId("redis-down-first"),
3423
+ text: "create incident",
3424
+ });
3425
+
3426
+ const first: DeliveryRecord = await deliverOnce(firstMessage);
3427
+ const redelivery: DeliveryRecord = await deliverOnce(firstMessage);
3428
+ jest.setSystemTime(start + 59 * 1000);
3429
+ const withinTheMinute: DeliveryRecord = await deliverOnce(
3430
+ buildMessageActivity({
3431
+ id: nextActivityId("redis-down-within-minute"),
3432
+ text: "create incident",
3433
+ }),
3434
+ );
3435
+ jest.setSystemTime(start + 61 * 1000);
3436
+ const afterTheMinute: DeliveryRecord = await deliverOnce(
3437
+ buildMessageActivity({
3438
+ id: nextActivityId("redis-down-after-minute"),
3439
+ text: "create incident",
3440
+ }),
3441
+ );
3442
+
3443
+ expect(
3444
+ [first, redelivery, withinTheMinute, afterTheMinute].map(
3445
+ (delivery: DeliveryRecord) => {
3446
+ return delivery.httpStatus;
3447
+ },
3448
+ ),
3449
+ ).toEqual([200, 200, 200, 200]);
3450
+ expect(labelsOf(first.calls)).toEqual(["Create New Incident"]);
3451
+ expect(redelivery.calls).toEqual([]);
3452
+ expect(labelsOf(withinTheMinute.calls)).toEqual(["Create New Incident"]);
3453
+ expect(labelsOf(afterTheMinute.calls)).toEqual(["Create New Incident"]);
3454
+
3455
+ // Four claims fell back; the first and the one a minute later are logged.
3456
+ const fallback: string =
3457
+ "Microsoft Teams activity dedupe fell back to this process's memory (Redis refused the claim); a Teams redelivery that reaches another App instance may be handled twice.";
3458
+ expect(loggedErrors).toEqual([
3459
+ fallback,
3460
+ "Error: Cache is not connected",
3461
+ fallback,
3462
+ "Error: Cache is not connected",
3463
+ ]);
3464
+ // Tagged as infrastructure, not as a fault in the code.
3465
+ for (const call of loggedErrorCalls) {
3466
+ expect(call.attributes).toStrictEqual({
3467
+ [ERROR_CLASS_ATTRIBUTE_KEY]: ErrorClass.Infrastructure,
3468
+ });
3469
+ }
3470
+ expect(outerConsoleErrorCalls).toEqual([]);
3471
+ });
3472
+
3473
+ test("a submit that carries another project's monitor (a tampered card): the real reference check refuses it, and the one '❌' reply says so in fixed words that never name that monitor; nothing is created and the form stays, HTTP 200", async () => {
3474
+ const opened: OpenedIncidentForm = await openIncidentForm();
3475
+ stubIncidentCreate();
3476
+ // The reference check itself runs; only the records it reads are stubbed.
3477
+ jest
3478
+ .spyOn(
3479
+ WorkspaceProjectReferenceValidator,
3480
+ "validateReferencesBelongToProject",
3481
+ )
3482
+ .mockRestore();
3483
+ const offeredMonitor: Monitor = new Monitor();
3484
+ offeredMonitor.id = new ObjectID(opened.project.monitors[0]!._id);
3485
+ offeredMonitor.name = opened.project.monitors[0]!.name;
3486
+ offeredMonitor.projectId = projectId;
3487
+ const foreignMonitor: Monitor = new Monitor();
3488
+ foreignMonitor.id = ObjectID.generate();
3489
+ foreignMonitor.name = "Payments API";
3490
+ foreignMonitor.projectId = ObjectID.generate();
3491
+ jest
3492
+ .spyOn(MonitorService, "findBy")
3493
+ .mockResolvedValue([offeredMonitor, foreignMonitor]);
3494
+ const pickedStatus: MonitorStatus = new MonitorStatus();
3495
+ pickedStatus.id = new ObjectID(opened.project.monitorStatuses[2]!._id);
3496
+ pickedStatus.name = opened.project.monitorStatuses[2]!.name;
3497
+ pickedStatus.projectId = projectId;
3498
+ jest
3499
+ .spyOn(MonitorStatusService, "findBy")
3500
+ .mockResolvedValue([pickedStatus]);
3501
+
3502
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
3503
+ buildCardSubmitActivity({
3504
+ id: nextActivityId("submit-foreign-monitor"),
3505
+ replyToId: opened.formId,
3506
+ value: {
3507
+ ...filledInIncidentForm(opened),
3508
+ // A monitor the form offered, and one it never could have.
3509
+ incidentMonitors: `${opened.project.monitors[0]!._id},${foreignMonitor.id!.toString()}`,
3510
+ },
3511
+ }),
3512
+ );
3513
+ const view: TeamsView = viewOf(deliveries);
3514
+
3515
+ // master: "❌ Failed to create incident. Please try again." and no reason.
3516
+ expect(view.httpStatuses).toEqual([200]);
3517
+ expect(
3518
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
3519
+ return [call.method, call.label, call.connectorStatus];
3520
+ }),
3521
+ ).toEqual([
3522
+ [
3523
+ "POST",
3524
+ "❌ Could not create the incident: One of the values you picked (a monitor, label, on-call policy, severity or status) is not available in this project any more. Please pick it again.",
3525
+ 200,
3526
+ ],
3527
+ ]);
3528
+ expect(MICROSOFT_TEAMS_UNAVAILABLE_REFERENCE_MESSAGE).toBe(
3529
+ "One of the values you picked (a monitor, label, on-call policy, severity or status) is not available in this project any more. Please pick it again.",
3530
+ );
3531
+ // The other project's monitor is named to nobody in the chat.
3532
+ for (const call of deliveries[0]!.calls) {
3533
+ expect(JSON.stringify(call.label)).not.toContain("Payments API");
3534
+ }
3535
+ // Nothing was created, and the form was left for a corrected submit.
3536
+ expect(IncidentService.create).not.toHaveBeenCalled();
3537
+ expect(view.deletes).toEqual([]);
3538
+ /*
3539
+ * The operator log does name it, with the class it was refused as: a
3540
+ * BadDataException with the same message, so API callers see no change.
3541
+ */
3542
+ const reason: string =
3543
+ 'This incident references records that belong to a different project: Monitor "Payments API". Please pick values from this project and try again.';
3544
+ expect(loggedErrors).toEqual([
3545
+ `Could not create an incident from Microsoft Teams: ProjectScopedReferenceException: ${reason}`,
3546
+ `Error: ${reason}`,
3547
+ ]);
3548
+ const refusal: unknown = loggedErrorCalls[1]?.message;
3549
+ expect(refusal).toBeInstanceOf(ProjectScopedReferenceException);
3550
+ expect(refusal).toBeInstanceOf(BadDataException);
3551
+ expectFailureLogged({
3552
+ at: 0,
3553
+ line: loggedErrors[0]!,
3554
+ error: refusal,
3555
+ attributes: { projectId: projectId.toString() },
3556
+ });
3557
+ expect(outerConsoleErrorCalls).toEqual([]);
3558
+ });
3559
+
3560
+ test("the create fails unexpectedly: the one '❌' reply says to try again or create it in OneUptime, with the link; nothing says why, the log does; the form stays, HTTP 200", async () => {
3561
+ const opened: OpenedIncidentForm = await openIncidentForm();
3562
+ stubIncidentCreate();
3563
+ const databaseFailure: Error = new Error(
3564
+ "simulated database failure: connection terminated",
3565
+ );
3566
+ jest.spyOn(IncidentService, "create").mockRejectedValue(databaseFailure);
3567
+
3568
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
3569
+ buildCardSubmitActivity({
3570
+ id: nextActivityId("submit-create-fails"),
3571
+ replyToId: opened.formId,
3572
+ value: filledInIncidentForm(opened),
3573
+ }),
3574
+ );
3575
+
3576
+ expect(
3577
+ deliveries.map((delivery: DeliveryRecord) => {
3578
+ return delivery.httpStatus;
3579
+ }),
3580
+ ).toEqual([200]);
3581
+ expect(
3582
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
3583
+ return [call.method, call.label, call.connectorStatus];
3584
+ }),
3585
+ ).toEqual([
3586
+ [
3587
+ "POST",
3588
+ `❌ Could not create the incident because of an unexpected error. Please try again, or create it in OneUptime: ${dashboardLink("/incidents/create")}`,
3589
+ 200,
3590
+ ],
3591
+ ]);
3592
+ expect(IncidentService.create).toHaveBeenCalledTimes(1);
3593
+ expect(loggedErrors).toEqual([
3594
+ "Could not create an incident from Microsoft Teams: Error: simulated database failure: connection terminated",
3595
+ "Error: simulated database failure: connection terminated",
3596
+ ]);
3597
+ expectFailureLogged({
3598
+ at: 0,
3599
+ line: loggedErrors[0]!,
3600
+ error: databaseFailure,
3601
+ attributes: { projectId: projectId.toString() },
3602
+ });
3603
+ expect(outerConsoleErrorCalls).toEqual([]);
3604
+ });
3605
+
3606
+ test("the create fails unexpectedly and OneUptime does not know its dashboard URL: the same reply, without a link", async () => {
3607
+ const opened: OpenedIncidentForm = await openIncidentForm();
3608
+ stubIncidentCreate();
3609
+ jest
3610
+ .spyOn(IncidentService, "create")
3611
+ .mockRejectedValue(new Error("simulated database failure"));
3612
+ withoutDashboardUrl();
3613
+
3614
+ const view: TeamsView = viewOf(
3615
+ await deliverLikeTeams(
3616
+ buildCardSubmitActivity({
3617
+ id: nextActivityId("submit-create-fails-no-link"),
3618
+ replyToId: opened.formId,
3619
+ value: filledInIncidentForm(opened),
3620
+ }),
3621
+ ),
3622
+ );
3623
+
3624
+ expect(view.httpStatuses).toEqual([200]);
3625
+ expect(labelsOf(view.bubbles)).toEqual([
3626
+ "❌ Could not create the incident because of an unexpected error. Please try again, or create it in OneUptime.",
3627
+ ]);
3628
+ expect(view.deletes).toEqual([]);
3629
+ });
3630
+
3631
+ /*
3632
+ * DELIBERATE, not an oversight: creating an incident from chat asks for
3633
+ * membership of the project, not for permission to create incidents. Slack
3634
+ * has the same policy, written down with its auth ("anyone in the company
3635
+ * can create incident", Server/Utils/Workspace/Slack/Actions/Auth.ts), and
3636
+ * commit 7ac7a3a03f, which put every other chat write behind the member's
3637
+ * own permissions, left incident creation as it was. A scheduled
3638
+ * maintenance event does need the permission (see the maintenance submit
3639
+ * tests). Change the product decision there before changing this.
3640
+ */
3641
+ test("a member whose role may not create incidents (a read-only Viewer) still creates one from the form: the real membership check runs, the permission check does not", async () => {
3642
+ const opened: OpenedIncidentForm = await openIncidentForm();
3643
+ stubIncidentCreate();
3644
+ useRealMembershipCheck([Permission.Viewer]);
3645
+
3646
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
3647
+ buildCardSubmitActivity({
3648
+ id: nextActivityId("submit-viewer"),
3649
+ replyToId: opened.formId,
3650
+ value: filledInIncidentForm(opened),
3651
+ }),
3652
+ );
3653
+
3654
+ expect(
3655
+ deliveries.map((delivery: DeliveryRecord) => {
3656
+ return delivery.httpStatus;
3657
+ }),
3658
+ ).toEqual([200]);
3659
+ expect(
3660
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
3661
+ return [call.method, call.label, call.connectorStatus];
3662
+ }),
3663
+ ).toEqual([
3664
+ ["POST", incidentCreatedConfirmation(), 200],
3665
+ ["DELETE", opened.formId, 200],
3666
+ ]);
3667
+ expect(IncidentService.create).toHaveBeenCalledTimes(1);
3668
+ // Membership was read from the database, for this user in this project.
3669
+ expect(TeamMemberService.findBy).toHaveBeenCalledTimes(1);
3670
+ expect(TeamMemberService.findBy).toHaveBeenCalledWith(
3671
+ expect.objectContaining({
3672
+ query: {
3673
+ userId: ONEUPTIME_USER_ID,
3674
+ projectId: projectId,
3675
+ hasAcceptedInvitation: true,
3676
+ },
3677
+ }),
3678
+ );
3679
+ expect(WorkspaceActionAuthorization.assertCanCreate).not.toHaveBeenCalled();
3680
+
3681
+ // Not for want of a check that would refuse this member: it would.
3682
+ const viewerProps: DatabaseCommonInteractionProps =
3683
+ await WorkspaceActionAuthorization.getProjectMemberProps({
3684
+ userId: ONEUPTIME_USER_ID,
3685
+ projectId: projectId,
3686
+ });
3687
+ await expect(
3688
+ WorkspaceActionAuthorization.assertCanCreate({
3689
+ props: viewerProps,
3690
+ modelType: Incident,
3691
+ action: "create an incident",
3692
+ }),
3693
+ ).rejects.toBeInstanceOf(NotAuthorizedException);
3694
+ expect(loggedErrors).toEqual([]);
3695
+ });
3696
+
3697
+ test("a Teams user who is no longer a member of the project is refused: one reply that says so, nothing created, the form stays, HTTP 200", async () => {
3698
+ const opened: OpenedIncidentForm = await openIncidentForm();
3699
+ stubIncidentCreate();
3700
+ useRealMembershipCheck(null);
3701
+
3702
+ const view: TeamsView = viewOf(
3703
+ await deliverLikeTeams(
3704
+ buildCardSubmitActivity({
3705
+ id: nextActivityId("submit-not-member"),
3706
+ replyToId: opened.formId,
3707
+ value: filledInIncidentForm(opened),
3708
+ }),
3709
+ ),
3710
+ );
3711
+
3712
+ expect(view.httpStatuses).toEqual([200]);
3713
+ expect(labelsOf(view.bubbles)).toEqual([
3714
+ "Your OneUptime account is not a member of this project. Ask a project admin to invite you, then try again.",
3715
+ ]);
3716
+ expect(IncidentService.create).not.toHaveBeenCalled();
3717
+ expect(view.deletes).toEqual([]);
3718
+ // A refusal, not a server error.
3719
+ expect(loggedErrors).toEqual([]);
3720
+ });
3721
+
3722
+ test("a form submitted in a group chat (Teams sends the submit without an @mention): created, confirmed and removed there, HTTP 200", async () => {
3723
+ const opened: OpenedIncidentForm = await openIncidentForm("groupChat");
3724
+ stubIncidentCreate();
3725
+ const submitId: string = nextActivityId("submit-group-chat");
3726
+
3727
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
3728
+ buildCardSubmitActivity({
3729
+ id: submitId,
3730
+ replyToId: opened.formId,
3731
+ value: filledInIncidentForm(opened),
3732
+ conversation: "groupChat",
3733
+ }),
3734
+ );
3735
+
3736
+ expect(
3737
+ deliveries.map((delivery: DeliveryRecord) => {
3738
+ return delivery.httpStatus;
3739
+ }),
3740
+ ).toEqual([200]);
3741
+ expect(
3742
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
3743
+ return [
3744
+ call.method,
3745
+ call.conversationId,
3746
+ call.activityIdInPath,
3747
+ call.connectorStatus,
3748
+ ];
3749
+ }),
3750
+ ).toEqual([
3751
+ ["POST", GROUP_CONVERSATION_ID, submitId, 200],
3752
+ ["DELETE", GROUP_CONVERSATION_ID, opened.formId, 200],
3753
+ ]);
3754
+ expect(labelsOf(deliveries[0]!.calls)[0]).toBe(
3755
+ incidentCreatedConfirmation(),
3756
+ );
3757
+ expect(IncidentService.create).toHaveBeenCalledTimes(1);
3758
+ });
3759
+ });
3760
+
3761
+ describe("submitting the maintenance form: start and end are read in the zone the form named, else the submitter's, through the real adapter", () => {
3762
+ const CREATED_MAINTENANCE_ID: ObjectID = ObjectID.generate();
3763
+
3764
+ function stubMaintenanceCreate(): void {
3765
+ jest.spyOn(ScheduledMaintenanceService, "create").mockImplementation(() => {
3766
+ const created: ScheduledMaintenance = new ScheduledMaintenance();
3767
+ created.id = CREATED_MAINTENANCE_ID;
3768
+ return Promise.resolve(created);
3769
+ });
3770
+ jest
3771
+ .spyOn(
3772
+ WorkspaceProjectReferenceValidator,
3773
+ "validateReferencesBelongToProject",
3774
+ )
3775
+ .mockResolvedValue(undefined);
3776
+ }
3777
+
3778
+ function createdMaintenanceData(): ScheduledMaintenance {
3779
+ const createSpy: ReturnType<typeof jest.spyOn> = jest.spyOn(
3780
+ ScheduledMaintenanceService,
3781
+ "create",
3782
+ );
3783
+ const firstCall: Array<unknown> | undefined = createSpy.mock.calls[0];
3784
+ return (firstCall?.[0] as { data: ScheduledMaintenance }).data;
3785
+ }
3786
+
3787
+ function maintenanceFormValue(data: {
3788
+ day: string;
3789
+ timezone?: string | undefined;
3790
+ // What the user picked from the form's optional lists, by input id.
3791
+ picked?: JSONObject | undefined;
3792
+ }): JSONObject {
3793
+ return {
3794
+ action:
3795
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
3796
+ ...(data.timezone ? { timezone: data.timezone } : {}),
3797
+ scheduledMaintenanceTitle: "Database Upgrade Window",
3798
+ scheduledMaintenanceDescription: "Upgrading the primary database.",
3799
+ startDate: data.day,
3800
+ startTime: "14:00",
3801
+ endDate: data.day,
3802
+ endTime: "16:00",
3803
+ ...(data.picked || {}),
3804
+ };
3805
+ }
3806
+
3807
+ test("a client that sends no zone on the submit (as iOS does): read in the zone the form was sent for, echoed back, and only then is the form removed", async () => {
3808
+ stubMaintenanceCreate();
3809
+ const day: string = moment
3810
+ .tz(SENDER_TIMEZONE)
3811
+ .add(7, "days")
3812
+ .format("YYYY-MM-DD");
3813
+ const monitorIds: Array<string> = [
3814
+ ObjectID.generate().toString(),
3815
+ ObjectID.generate().toString(),
3816
+ ];
3817
+ const monitorStatusId: string = ObjectID.generate().toString();
3818
+ const labelId: string = ObjectID.generate().toString();
3819
+
3820
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
3821
+ buildCardSubmitActivity({
3822
+ id: nextActivityId("submit-maintenance-card-zone"),
3823
+ replyToId: "msg-maintenance-form",
3824
+ value: maintenanceFormValue({
3825
+ day: day,
3826
+ timezone: SENDER_TIMEZONE,
3827
+ picked: {
3828
+ scheduledMaintenanceMonitors: monitorIds.join(","),
3829
+ monitorStatus: monitorStatusId,
3830
+ labels: labelId,
3831
+ },
3832
+ }),
3833
+ }),
3834
+ );
3835
+ const view: TeamsView = viewOf(deliveries);
3836
+
3837
+ const startsAt: Date = moment
3838
+ .tz(`${day} 14:00`, "YYYY-MM-DD HH:mm", SENDER_TIMEZONE)
3839
+ .toDate();
3840
+ const endsAt: Date = moment
3841
+ .tz(`${day} 16:00`, "YYYY-MM-DD HH:mm", SENDER_TIMEZONE)
3842
+ .toDate();
3843
+ const dayAsRead: string = moment
3844
+ .tz(day, "YYYY-MM-DD", SENDER_TIMEZONE)
3845
+ .format("MMM D, YYYY");
3846
+
3847
+ expect(view.httpStatuses).toEqual([200]);
3848
+ // master removed the form first, and a failed removal hid the confirmation.
3849
+ expect(
3850
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
3851
+ return [call.method, call.label, call.connectorStatus];
3852
+ }),
3853
+ ).toEqual([
3854
+ [
3855
+ "POST",
3856
+ `✅ Scheduled maintenance created successfully!\n\n**Starts:** ${dayAsRead}, 14:00 (${SENDER_TIMEZONE})\n\n**Ends:** ${dayAsRead}, 16:00 (${SENDER_TIMEZONE})\n\nView scheduled maintenance: ${dashboardLink(`/scheduled-maintenance-events/${CREATED_MAINTENANCE_ID.toString()}`)}`,
3857
+ 200,
3858
+ ],
3859
+ ["DELETE", "msg-maintenance-form", 200],
3860
+ ]);
3861
+
3862
+ expect(ScheduledMaintenanceService.create).toHaveBeenCalledTimes(1);
3863
+ const maintenance: ScheduledMaintenance = createdMaintenanceData();
3864
+ expect(maintenance.startsAt?.toISOString()).toBe(startsAt.toISOString());
3865
+ expect(maintenance.endsAt?.toISOString()).toBe(endsAt.toISOString());
3866
+ expect(maintenance.title).toBe("Database Upgrade Window");
3867
+ expect(maintenance.projectId?.toString()).toBe(projectId.toString());
3868
+ expect(maintenance.createdByUserId?.toString()).toBe(
3869
+ ONEUPTIME_USER_ID.toString(),
3870
+ );
3871
+ expect(
3872
+ (maintenance.monitors || []).map((monitor: Monitor) => {
3873
+ return monitor.id?.toString();
3874
+ }),
3875
+ ).toEqual(monitorIds);
3876
+ expect(
3877
+ (maintenance.labels || []).map((label: Label) => {
3878
+ return label.id?.toString();
3879
+ }),
3880
+ ).toEqual([labelId]);
3881
+ // The status change goes through the event, not onto the monitors.
3882
+ expect(maintenance.changeMonitorStatusToId?.toString()).toBe(
3883
+ monitorStatusId,
3884
+ );
3885
+ expect(loggedErrors).toEqual([]);
3886
+ });
3887
+
3888
+ test("no zone anywhere: read at the UTC offset of the activity's local timestamp, which botbuilder hands over as rawLocalTimestamp, and the reply says it was read at that offset", async () => {
3889
+ stubMaintenanceCreate();
3890
+ const day: string = moment.utc().add(7, "days").format("YYYY-MM-DD");
3891
+
3892
+ const view: TeamsView = viewOf(
3893
+ await deliverLikeTeams(
3894
+ buildCardSubmitActivity({
3895
+ id: nextActivityId("submit-maintenance-offset"),
3896
+ replyToId: "msg-maintenance-form-2",
3897
+ localTimestamp: "2026-09-29T15:35:00.000+05:30",
3898
+ value: maintenanceFormValue({ day: day }),
3899
+ }),
3900
+ ),
3901
+ );
3902
+
3903
+ const dayAsRead: string = moment
3904
+ .utc(day, "YYYY-MM-DD")
3905
+ .format("MMM D, YYYY");
3906
+
3907
+ /*
3908
+ * Today's offset, which a daylight saving change before the event would
3909
+ * make an hour out: the reply says so, and where to check.
3910
+ */
3911
+ expect(view.httpStatuses).toEqual([200]);
3912
+ expect(labelsOf(view.bubbles)).toEqual([
3913
+ `✅ Scheduled maintenance created successfully!\n\n**Starts:** ${dayAsRead}, 14:00 (UTC+05:30)\n\n**Ends:** ${dayAsRead}, 16:00 (UTC+05:30)\n\nMicrosoft Teams did not say which time zone you are in, so these times were read at your current offset, UTC+05:30. If daylight saving time changes before then, check them in OneUptime.\n\nView scheduled maintenance: ${dashboardLink(`/scheduled-maintenance-events/${CREATED_MAINTENANCE_ID.toString()}`)}`,
3914
+ ]);
3915
+
3916
+ // 14:00 at UTC+05:30 is 08:30 UTC.
3917
+ const maintenance: ScheduledMaintenance = createdMaintenanceData();
3918
+ expect(maintenance.startsAt?.toISOString()).toBe(`${day}T08:30:00.000Z`);
3919
+ expect(maintenance.endsAt?.toISOString()).toBe(`${day}T10:30:00.000Z`);
3920
+ });
3921
+
3922
+ test("a form sent to a group chat for New York, filled in by someone whose Teams says Berlin: read in New York, the zone the form named, and named back", async () => {
3923
+ stubMaintenanceCreate();
3924
+ const day: string = moment
3925
+ .tz(SENDER_TIMEZONE)
3926
+ .add(7, "days")
3927
+ .format("YYYY-MM-DD");
3928
+
3929
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
3930
+ buildCardSubmitActivity({
3931
+ id: nextActivityId("submit-maintenance-other-zone"),
3932
+ replyToId: "msg-maintenance-form-group",
3933
+ conversation: "groupChat",
3934
+ from: OTHER_MEMBER,
3935
+ localTimezone: "Europe/Berlin",
3936
+ localTimestamp: "2026-09-29T12:05:00.000+02:00",
3937
+ value: maintenanceFormValue({ day: day, timezone: SENDER_TIMEZONE }),
3938
+ }),
3939
+ );
3940
+
3941
+ const dayAsRead: string = moment
3942
+ .tz(day, "YYYY-MM-DD", SENDER_TIMEZONE)
3943
+ .format("MMM D, YYYY");
3944
+
3945
+ /*
3946
+ * The form told whoever filled it in that its times are in New York, so
3947
+ * 14:00 is 14:00 there: read in Berlin, the event would start six hours
3948
+ * early.
3949
+ */
3950
+ expect(
3951
+ deliveries.map((delivery: DeliveryRecord) => {
3952
+ return delivery.httpStatus;
3953
+ }),
3954
+ ).toEqual([200]);
3955
+ expect(
3956
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
3957
+ return [call.method, call.conversationId, call.label];
3958
+ }),
3959
+ ).toEqual([
3960
+ [
3961
+ "POST",
3962
+ GROUP_CONVERSATION_ID,
3963
+ `✅ Scheduled maintenance created successfully!\n\n**Starts:** ${dayAsRead}, 14:00 (${SENDER_TIMEZONE})\n\n**Ends:** ${dayAsRead}, 16:00 (${SENDER_TIMEZONE})\n\nView scheduled maintenance: ${dashboardLink(`/scheduled-maintenance-events/${CREATED_MAINTENANCE_ID.toString()}`)}`,
3964
+ ],
3965
+ ["DELETE", GROUP_CONVERSATION_ID, "msg-maintenance-form-group"],
3966
+ ]);
3967
+ const maintenance: ScheduledMaintenance = createdMaintenanceData();
3968
+ expect(maintenance.startsAt?.toISOString()).toBe(
3969
+ moment
3970
+ .tz(`${day} 14:00`, "YYYY-MM-DD HH:mm", SENDER_TIMEZONE)
3971
+ .toDate()
3972
+ .toISOString(),
3973
+ );
3974
+ // Created by whoever submitted it, as their own linked account.
3975
+ expect(
3976
+ MicrosoftTeamsAuthAction.getOneUptimeUserIdFromTeamsUserId,
3977
+ ).toHaveBeenCalledWith({
3978
+ teamsUserId: OTHER_MEMBER["aadObjectId"],
3979
+ projectId: projectId,
3980
+ });
3981
+ });
3982
+
3983
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
3984
+ type JustGoneByCase = {
3985
+ description: string;
3986
+ // When the submit arrives: October 6, 2026, on the clock in New York.
3987
+ arrivesAt: string;
3988
+ endTime: string;
3989
+ // The one reply.
3990
+ reply: string;
3991
+ // When the event starts, if it was created.
3992
+ startsAt: string | null;
3993
+ };
3994
+
3995
+ /*
3996
+ * Input.Time has minute precision, so a start picked as "now" is a minute
3997
+ * or two in the past by the time the form is filled in. A start up to five
3998
+ * minutes gone by begins now; an older one is refused, as before.
3999
+ */
4000
+ test.each<JustGoneByCase>([
4001
+ {
4002
+ description: "4:59 ago: the event is created, starting now",
4003
+ arrivesAt: "14:04:59",
4004
+ endTime: "16:00",
4005
+ reply: `✅ Scheduled maintenance created successfully!\n\n**Starts:** Oct 6, 2026, 14:04 (${SENDER_TIMEZONE})\n\n**Ends:** Oct 6, 2026, 16:00 (${SENDER_TIMEZONE})\n\nView scheduled maintenance: `,
4006
+ startsAt: "2026-10-06T18:04:59.000Z",
4007
+ },
4008
+ {
4009
+ description: "exactly 5:00 ago: still created, starting now",
4010
+ arrivesAt: "14:05:00",
4011
+ endTime: "16:00",
4012
+ reply: `✅ Scheduled maintenance created successfully!\n\n**Starts:** Oct 6, 2026, 14:05 (${SENDER_TIMEZONE})\n\n**Ends:** Oct 6, 2026, 16:00 (${SENDER_TIMEZONE})\n\nView scheduled maintenance: `,
4013
+ startsAt: "2026-10-06T18:05:00.000Z",
4014
+ },
4015
+ {
4016
+ description: "5:01 ago: refused as in the past",
4017
+ arrivesAt: "14:05:01",
4018
+ endTime: "16:00",
4019
+ reply: `Unable to create scheduled maintenance: the start time (Oct 6, 2026, 14:00 (${SENDER_TIMEZONE})) is in the past.`,
4020
+ startsAt: null,
4021
+ },
4022
+ {
4023
+ description:
4024
+ "4:59 ago, but ending before now: refused, the end must come after the start it was moved to",
4025
+ arrivesAt: "14:04:59",
4026
+ endTime: "14:03",
4027
+ reply: `Unable to create scheduled maintenance: the end time (Oct 6, 2026, 14:03 (${SENDER_TIMEZONE})) must be after the start time (Oct 6, 2026, 14:04 (${SENDER_TIMEZONE})).`,
4028
+ startsAt: null,
4029
+ },
4030
+ ])(
4031
+ "a start of 14:00 that has just gone by, $description",
4032
+ async (goneBy: JustGoneByCase) => {
4033
+ stubMaintenanceCreate();
4034
+ stopTheClockAt(
4035
+ moment
4036
+ .tz(
4037
+ `2026-10-06 ${goneBy.arrivesAt}`,
4038
+ "YYYY-MM-DD HH:mm:ss",
4039
+ SENDER_TIMEZONE,
4040
+ )
4041
+ .toDate(),
4042
+ );
4043
+
4044
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
4045
+ buildCardSubmitActivity({
4046
+ id: nextActivityId("submit-maintenance-just-gone-by"),
4047
+ replyToId: "msg-maintenance-form-now",
4048
+ value: {
4049
+ ...maintenanceFormValue({
4050
+ day: "2026-10-06",
4051
+ timezone: SENDER_TIMEZONE,
4052
+ }),
4053
+ endTime: goneBy.endTime,
4054
+ },
4055
+ }),
4056
+ );
4057
+ const view: TeamsView = viewOf(deliveries);
4058
+
4059
+ expect(view.httpStatuses).toEqual([200]);
4060
+ expect(labelsOf(view.bubbles)).toEqual([
4061
+ goneBy.startsAt
4062
+ ? `${goneBy.reply}${dashboardLink(`/scheduled-maintenance-events/${CREATED_MAINTENANCE_ID.toString()}`)}`
4063
+ : goneBy.reply,
4064
+ ]);
4065
+
4066
+ if (goneBy.startsAt) {
4067
+ expect(ScheduledMaintenanceService.create).toHaveBeenCalledTimes(1);
4068
+ const maintenance: ScheduledMaintenance = createdMaintenanceData();
4069
+ expect(maintenance.startsAt?.toISOString()).toBe(goneBy.startsAt);
4070
+ expect(maintenance.endsAt?.toISOString()).toBe(
4071
+ "2026-10-06T20:00:00.000Z",
4072
+ );
4073
+ expect(labelsOf(view.deletes)).toEqual(["msg-maintenance-form-now"]);
4074
+ } else {
4075
+ // Refused: nothing created, and the form stays to pick again.
4076
+ expect(ScheduledMaintenanceService.create).not.toHaveBeenCalled();
4077
+ expect(view.deletes).toEqual([]);
4078
+ }
4079
+ expect(loggedErrors).toEqual([]);
4080
+ },
4081
+ );
4082
+
4083
+ test("a submit that picks a label deleted since the form was sent: the real reference check refuses it, and the one '❌' reply says so in fixed words; nothing is created, HTTP 200", async () => {
4084
+ stubMaintenanceCreate();
4085
+ // The reference check itself runs; only the records it reads are stubbed.
4086
+ jest
4087
+ .spyOn(
4088
+ WorkspaceProjectReferenceValidator,
4089
+ "validateReferencesBelongToProject",
4090
+ )
4091
+ .mockRestore();
4092
+ // The label is gone: the check finds no record for it.
4093
+ jest.spyOn(LabelService, "findBy").mockResolvedValue([]);
4094
+ const deletedLabelId: string = ObjectID.generate().toString();
4095
+ const day: string = moment
4096
+ .tz(SENDER_TIMEZONE)
4097
+ .add(7, "days")
4098
+ .format("YYYY-MM-DD");
4099
+
4100
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
4101
+ buildCardSubmitActivity({
4102
+ id: nextActivityId("submit-maintenance-deleted-label"),
4103
+ replyToId: "msg-maintenance-form-deleted-label",
4104
+ value: maintenanceFormValue({
4105
+ day: day,
4106
+ timezone: SENDER_TIMEZONE,
4107
+ picked: { labels: deletedLabelId },
4108
+ }),
4109
+ }),
4110
+ );
4111
+
4112
+ expect(
4113
+ deliveries.map((delivery: DeliveryRecord) => {
4114
+ return delivery.httpStatus;
4115
+ }),
4116
+ ).toEqual([200]);
4117
+ expect(
4118
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
4119
+ return [call.method, call.label, call.connectorStatus];
4120
+ }),
4121
+ ).toEqual([
4122
+ [
4123
+ "POST",
4124
+ `❌ Could not create the scheduled maintenance event: ${MICROSOFT_TEAMS_UNAVAILABLE_REFERENCE_MESSAGE}`,
4125
+ 200,
4126
+ ],
4127
+ ]);
4128
+ expect(ScheduledMaintenanceService.create).not.toHaveBeenCalled();
4129
+ const reason: string = `This scheduled maintenance event references records that do not exist: Label "${deletedLabelId}". Please pick values that exist in this project and try again.`;
4130
+ expect(loggedErrors).toEqual([
4131
+ `Could not create a scheduled maintenance event from Microsoft Teams: ProjectScopedReferenceException: ${reason}`,
4132
+ `Error: ${reason}`,
4133
+ ]);
4134
+ expect(loggedErrorCalls[1]?.message).toBeInstanceOf(
4135
+ ProjectScopedReferenceException,
4136
+ );
4137
+ expect(outerConsoleErrorCalls).toEqual([]);
4138
+ });
4139
+
4140
+ test("OneUptime refuses the create for a reason written for the user (the project has no scheduled state): the one '❌' reply gives that reason, HTTP 200", async () => {
4141
+ stubMaintenanceCreate();
4142
+ // What ScheduledMaintenanceService.onBeforeCreate throws in that case.
4143
+ const refusal: BadDataException = new BadDataException(
4144
+ "Scheduled state not found for this project. Please add an scheduled event state from settings.",
4145
+ );
4146
+ jest
4147
+ .spyOn(ScheduledMaintenanceService, "create")
4148
+ .mockRejectedValue(refusal);
4149
+ const day: string = moment
4150
+ .tz(SENDER_TIMEZONE)
4151
+ .add(7, "days")
4152
+ .format("YYYY-MM-DD");
4153
+
4154
+ const view: TeamsView = viewOf(
4155
+ await deliverLikeTeams(
4156
+ buildCardSubmitActivity({
4157
+ id: nextActivityId("submit-maintenance-refused"),
4158
+ replyToId: "msg-maintenance-form-refused",
4159
+ value: maintenanceFormValue({ day: day, timezone: SENDER_TIMEZONE }),
4160
+ }),
4161
+ ),
4162
+ );
4163
+
4164
+ expect(view.httpStatuses).toEqual([200]);
4165
+ expect(labelsOf(view.bubbles)).toEqual([
4166
+ `❌ Could not create the scheduled maintenance event: ${refusal.message}`,
4167
+ ]);
4168
+ expect(view.deletes).toEqual([]);
4169
+ // The class it was refused as, from the constructor: its name is Error's.
4170
+ expect(loggedErrors).toEqual([
4171
+ `Could not create a scheduled maintenance event from Microsoft Teams: BadDataException: ${refusal.message}`,
4172
+ `Error: ${refusal.message}`,
4173
+ ]);
4174
+ expectFailureLogged({
4175
+ at: 0,
4176
+ line: loggedErrors[0]!,
4177
+ error: refusal,
4178
+ attributes: { projectId: projectId.toString() },
4179
+ });
4180
+ });
4181
+
4182
+ test("the create fails unexpectedly: the one '❌' reply says to try again or create it in OneUptime, with the link, HTTP 200", async () => {
4183
+ stubMaintenanceCreate();
4184
+ const databaseFailure: Error = new Error("simulated database failure");
4185
+ jest
4186
+ .spyOn(ScheduledMaintenanceService, "create")
4187
+ .mockRejectedValue(databaseFailure);
4188
+ const day: string = moment
4189
+ .tz(SENDER_TIMEZONE)
4190
+ .add(7, "days")
4191
+ .format("YYYY-MM-DD");
4192
+
4193
+ const view: TeamsView = viewOf(
4194
+ await deliverLikeTeams(
4195
+ buildCardSubmitActivity({
4196
+ id: nextActivityId("submit-maintenance-create-fails"),
4197
+ replyToId: "msg-maintenance-form-fails",
4198
+ value: maintenanceFormValue({ day: day, timezone: SENDER_TIMEZONE }),
4199
+ }),
4200
+ ),
4201
+ );
4202
+
4203
+ expect(view.httpStatuses).toEqual([200]);
4204
+ expect(labelsOf(view.bubbles)).toEqual([
4205
+ `❌ Could not create the scheduled maintenance event because of an unexpected error. Please try again, or create it in OneUptime: ${dashboardLink("/scheduled-maintenance-events/create")}`,
4206
+ ]);
4207
+ expect(view.deletes).toEqual([]);
4208
+ expect(loggedErrors).toEqual([
4209
+ "Could not create a scheduled maintenance event from Microsoft Teams: Error: simulated database failure",
4210
+ "Error: simulated database failure",
4211
+ ]);
4212
+ expectFailureLogged({
4213
+ at: 0,
4214
+ line: loggedErrors[0]!,
4215
+ error: databaseFailure,
4216
+ attributes: { projectId: projectId.toString() },
4217
+ });
4218
+ });
4219
+
4220
+ test("a member whose role may not create scheduled maintenance (a read-only Viewer) is refused by the real permission check: one reply that says why, nothing created (unlike an incident, see the incident submit tests)", async () => {
4221
+ stubMaintenanceCreate();
4222
+ useRealMembershipCheck([Permission.Viewer]);
4223
+ const day: string = moment
4224
+ .tz(SENDER_TIMEZONE)
4225
+ .add(7, "days")
4226
+ .format("YYYY-MM-DD");
4227
+
4228
+ const view: TeamsView = viewOf(
4229
+ await deliverLikeTeams(
4230
+ buildCardSubmitActivity({
4231
+ id: nextActivityId("submit-maintenance-viewer"),
4232
+ replyToId: "msg-maintenance-form-viewer",
4233
+ value: maintenanceFormValue({ day: day, timezone: SENDER_TIMEZONE }),
4234
+ }),
4235
+ ),
4236
+ );
4237
+
4238
+ expect(view.httpStatuses).toEqual([200]);
4239
+ expect(labelsOf(view.bubbles)).toEqual([
4240
+ "You do not have permission to create a scheduled maintenance event. You do not have permissions to create Scheduled Maintenance Event. You need one of these permissions: Project Owner, Project Admin, Project Member, Scheduled Maintenance Admin, Scheduled Maintenance Member, Create Scheduled Maintenance",
4241
+ ]);
4242
+ expect(WorkspaceActionAuthorization.assertCanCreate).toHaveBeenCalledTimes(
4243
+ 1,
4244
+ );
4245
+ expect(ScheduledMaintenanceService.create).not.toHaveBeenCalled();
4246
+ expect(view.deletes).toEqual([]);
4247
+ expect(loggedErrors).toEqual([]);
4248
+ });
4249
+ });
4250
+
4251
+ describe("a form action that happened is never reported as failed (#4111 review): when Teams refuses its confirmation, that refused reply is the only one, the form is removed, HTTP 200", () => {
4252
+ const RECORD_ID: string = ObjectID.generate().toString();
4253
+ const ON_CALL_POLICY_ID: string = ObjectID.generate().toString();
4254
+ const STATE_ID: string = ObjectID.generate().toString();
4255
+ const NOTE: string =
4256
+ "Rolled back the 14:02 deploy; the error rate is normal.";
4257
+
4258
+ // A stubbed write, as the test checks it: done once.
4259
+ type StubbedWrite = { mock: { calls: Array<Array<unknown>> } };
4260
+
4261
+ // A type, not an interface: test.each wants a Record<string, unknown> row.
4262
+ type DoneActionCase = {
4263
+ name: string;
4264
+ action: string;
4265
+ // The record the form acts on; a maintenance form carries it in `fields`.
4266
+ actionValue: string;
4267
+ fields: JSONObject;
4268
+ confirmation: string;
4269
+ stubWrite: () => StubbedWrite;
4270
+ };
4271
+
4272
+ function stubExecutePolicy(): StubbedWrite {
4273
+ return jest
4274
+ .spyOn(OnCallDutyPolicyService, "executePolicy")
4275
+ .mockResolvedValue(undefined);
4276
+ }
4277
+
4278
+ // The event a maintenance form acts on, read before the action.
4279
+ function stubExistingMaintenance(): void {
4280
+ const maintenance: ScheduledMaintenance = new ScheduledMaintenance();
4281
+ maintenance.id = new ObjectID(RECORD_ID);
4282
+ maintenance.projectId = projectId;
4283
+ maintenance.title = "Database upgrade";
4284
+ jest
4285
+ .spyOn(ScheduledMaintenanceService, "findOneBy")
4286
+ .mockResolvedValue(maintenance);
4287
+ }
4288
+
4289
+ /*
4290
+ * Every form submit that confirms with a reply and then removes its form,
4291
+ * but the two create forms (whose confirmation the submit tests above
4292
+ * refuse): one row per handler in Actions/.
4293
+ */
4294
+ test.each<DoneActionCase>([
4295
+ {
4296
+ name: "incident: Add Note",
4297
+ action: MicrosoftTeamsIncidentActionType.SubmitIncidentNote,
4298
+ actionValue: RECORD_ID,
4299
+ fields: { noteType: "public", note: NOTE },
4300
+ confirmation: "✅ Note added successfully.",
4301
+ stubWrite: (): StubbedWrite => {
4302
+ return jest
4303
+ .spyOn(IncidentPublicNoteService, "addNote")
4304
+ .mockResolvedValue(new IncidentPublicNote());
4305
+ },
4306
+ },
4307
+ {
4308
+ name: "incident: Execute On-Call Policy",
4309
+ action:
4310
+ MicrosoftTeamsIncidentActionType.SubmitExecuteIncidentOnCallPolicy,
4311
+ actionValue: RECORD_ID,
4312
+ fields: { onCallPolicy: ON_CALL_POLICY_ID },
4313
+ confirmation: "✅ On-call policy executed successfully.",
4314
+ stubWrite: stubExecutePolicy,
4315
+ },
4316
+ {
4317
+ name: "incident: Change Incident State",
4318
+ action: MicrosoftTeamsIncidentActionType.SubmitChangeIncidentState,
4319
+ actionValue: RECORD_ID,
4320
+ fields: { incidentState: STATE_ID },
4321
+ confirmation: "✅ Incident state changed successfully.",
4322
+ stubWrite: (): StubbedWrite => {
4323
+ return jest
4324
+ .spyOn(IncidentService, "updateOneById")
4325
+ .mockResolvedValue(1);
4326
+ },
4327
+ },
4328
+ {
4329
+ name: "alert: Add Note",
4330
+ action: MicrosoftTeamsAlertActionType.SubmitAlertNote,
4331
+ actionValue: RECORD_ID,
4332
+ fields: { note: NOTE },
4333
+ confirmation: "✅ Note added successfully.",
4334
+ stubWrite: (): StubbedWrite => {
4335
+ return jest
4336
+ .spyOn(AlertInternalNoteService, "addNote")
4337
+ .mockResolvedValue(new AlertInternalNote());
4338
+ },
4339
+ },
4340
+ {
4341
+ name: "alert: Execute On-Call Policy",
4342
+ action: MicrosoftTeamsAlertActionType.SubmitExecuteAlertOnCallPolicy,
4343
+ actionValue: RECORD_ID,
4344
+ fields: { onCallPolicy: ON_CALL_POLICY_ID },
4345
+ confirmation: "✅ On-call policy executed successfully.",
4346
+ stubWrite: stubExecutePolicy,
4347
+ },
4348
+ {
4349
+ name: "alert: Change Alert State",
4350
+ action: MicrosoftTeamsAlertActionType.SubmitChangeAlertState,
4351
+ actionValue: RECORD_ID,
4352
+ fields: { alertState: STATE_ID },
4353
+ confirmation: "✅ Alert state changed successfully.",
4354
+ stubWrite: (): StubbedWrite => {
4355
+ return jest.spyOn(AlertService, "updateOneById").mockResolvedValue(1);
4356
+ },
4357
+ },
4358
+ {
4359
+ name: "alert episode: Add Note",
4360
+ action: MicrosoftTeamsAlertEpisodeActionType.SubmitAlertEpisodeNote,
4361
+ actionValue: RECORD_ID,
4362
+ fields: { note: NOTE },
4363
+ confirmation: "✅ Note added successfully.",
4364
+ stubWrite: (): StubbedWrite => {
4365
+ return jest
4366
+ .spyOn(AlertEpisodeInternalNoteService, "addNote")
4367
+ .mockResolvedValue(new AlertEpisodeInternalNote());
4368
+ },
4369
+ },
4370
+ {
4371
+ name: "alert episode: Execute On-Call Policy",
4372
+ action:
4373
+ MicrosoftTeamsAlertEpisodeActionType.SubmitExecuteAlertEpisodeOnCallPolicy,
4374
+ actionValue: RECORD_ID,
4375
+ fields: { onCallPolicy: ON_CALL_POLICY_ID },
4376
+ confirmation: "✅ On-call policy executed successfully.",
4377
+ stubWrite: stubExecutePolicy,
4378
+ },
4379
+ {
4380
+ name: "alert episode: Change Episode State",
4381
+ action:
4382
+ MicrosoftTeamsAlertEpisodeActionType.SubmitChangeAlertEpisodeState,
4383
+ actionValue: RECORD_ID,
4384
+ fields: { alertState: STATE_ID },
4385
+ confirmation: "✅ Alert episode state changed successfully.",
4386
+ stubWrite: (): StubbedWrite => {
4387
+ return jest
4388
+ .spyOn(AlertEpisodeService, "changeEpisodeState")
4389
+ .mockResolvedValue(undefined);
4390
+ },
4391
+ },
4392
+ {
4393
+ name: "incident episode: Add Note",
4394
+ action: MicrosoftTeamsIncidentEpisodeActionType.SubmitIncidentEpisodeNote,
4395
+ actionValue: RECORD_ID,
4396
+ fields: { note: NOTE },
4397
+ confirmation: "Note added successfully.",
4398
+ stubWrite: (): StubbedWrite => {
4399
+ return jest
4400
+ .spyOn(IncidentEpisodeInternalNoteService, "addNote")
4401
+ .mockResolvedValue(new IncidentEpisodeInternalNote());
4402
+ },
4403
+ },
4404
+ {
4405
+ name: "incident episode: Execute On-Call Policy",
4406
+ action:
4407
+ MicrosoftTeamsIncidentEpisodeActionType.SubmitExecuteIncidentEpisodeOnCallPolicy,
4408
+ actionValue: RECORD_ID,
4409
+ fields: { onCallPolicy: ON_CALL_POLICY_ID },
4410
+ confirmation: "On-call policy executed successfully.",
4411
+ stubWrite: stubExecutePolicy,
4412
+ },
4413
+ {
4414
+ name: "incident episode: Change Episode State",
4415
+ action:
4416
+ MicrosoftTeamsIncidentEpisodeActionType.SubmitChangeIncidentEpisodeState,
4417
+ actionValue: RECORD_ID,
4418
+ fields: { incidentState: STATE_ID },
4419
+ confirmation: "Incident episode state changed successfully.",
4420
+ stubWrite: (): StubbedWrite => {
4421
+ return jest
4422
+ .spyOn(IncidentEpisodeService, "changeEpisodeState")
4423
+ .mockResolvedValue(undefined);
4424
+ },
4425
+ },
4426
+ {
4427
+ name: "scheduled maintenance: Add Note",
4428
+ action:
4429
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitScheduledMaintenanceNote,
4430
+ actionValue: "",
4431
+ fields: { scheduledMaintenanceId: RECORD_ID, note: NOTE, isPublic: true },
4432
+ confirmation: "Note added successfully",
4433
+ stubWrite: (): StubbedWrite => {
4434
+ stubExistingMaintenance();
4435
+ return jest
4436
+ .spyOn(ScheduledMaintenancePublicNoteService, "addNote")
4437
+ .mockResolvedValue(new ScheduledMaintenancePublicNote());
4438
+ },
4439
+ },
4440
+ {
4441
+ name: "scheduled maintenance: Change State",
4442
+ action:
4443
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitChangeScheduledMaintenanceState,
4444
+ actionValue: "",
4445
+ fields: { scheduledMaintenanceId: RECORD_ID, stateId: STATE_ID },
4446
+ confirmation: "ScheduledMaintenance state changed successfully",
4447
+ stubWrite: (): StubbedWrite => {
4448
+ stubExistingMaintenance();
4449
+ return jest
4450
+ .spyOn(ScheduledMaintenanceService, "updateOneById")
4451
+ .mockResolvedValue(1);
4452
+ },
4453
+ },
4454
+ ])("$name", async (done: DoneActionCase) => {
4455
+ const write: StubbedWrite = done.stubWrite();
4456
+ // Teams refuses the confirmation, and nothing else.
4457
+ connector.refuse = (request: ConnectorRequest): ConnectorAnswer | null => {
4458
+ return request.method === "POST" &&
4459
+ request.activity?.["text"] === done.confirmation
4460
+ ? MESSAGE_REFUSED_ANSWER
4461
+ : null;
4462
+ };
4463
+ const formId: string = `msg-form-${nextActivityId("done-action")}`;
4464
+
4465
+ const deliveries: Array<DeliveryRecord> = await deliverLikeTeams(
4466
+ buildCardSubmitActivity({
4467
+ id: nextActivityId("done-action-submit"),
4468
+ replyToId: formId,
4469
+ value: {
4470
+ action: done.action,
4471
+ actionValue: done.actionValue,
4472
+ ...done.fields,
4473
+ },
4474
+ }),
4475
+ );
4476
+
4477
+ /*
4478
+ * Before: the refusal escaped into "Sorry, that action failed. Please try
4479
+ * again later." (for maintenance, "An error occurred while processing the
4480
+ * action") under an action that had happened, and the form stayed to be
4481
+ * submitted again: a second note, a second page of the on-call policy.
4482
+ */
4483
+ expect(
4484
+ deliveries.map((delivery: DeliveryRecord) => {
4485
+ return delivery.httpStatus;
4486
+ }),
4487
+ ).toEqual([200]);
4488
+ expect(
4489
+ deliveries[0]!.calls.map((call: ConnectorCall) => {
4490
+ return [call.method, call.label, call.connectorStatus];
4491
+ }),
4492
+ ).toEqual([
4493
+ ["POST", done.confirmation, 403],
4494
+ ["DELETE", formId, 200],
4495
+ ]);
4496
+ expect(write.mock.calls).toHaveLength(1);
4497
+ // The operator can see why the user got no confirmation.
4498
+ expect(loggedErrors).toEqual([
4499
+ "Could not send a Microsoft Teams reply: RestError 403 Forbidden: The message was refused.",
4500
+ ]);
4501
+ expect(outerConsoleErrorCalls).toEqual([]);
4502
+ });
4503
+ });
4504
+
4505
+ describe("card size sweep: whatever the project, the form Teams gets fits the 40 KiB card budget", () => {
4506
+ const MONITOR_COUNTS: Array<number> = [
4507
+ 0, 1, 50, 150, 200, 249, 250, 251, 400, 800, 1000, 2000, 5000,
4508
+ ];
4509
+
4510
+ interface SweepShape {
4511
+ description: string;
4512
+ shape: Omit<ProjectShape, "monitors">;
4513
+ }
4514
+
4515
+ const SHAPES: Array<SweepShape> = [
4516
+ {
4517
+ description: "30-character names, 20 labels, 10 on-call policies",
4518
+ shape: {},
4519
+ },
4520
+ {
4521
+ description:
4522
+ "100-character names, 150 labels, 120 on-call policies (both over what is read)",
4523
+ shape: { longNames: true, labels: 150, onCallPolicies: 120 },
4524
+ },
4525
+ {
4526
+ description:
4527
+ "100-character names and every list at the most that is read, 50 severities and 50 monitor statuses included",
4528
+ shape: {
4529
+ longNames: true,
4530
+ labels: 100,
4531
+ onCallPolicies: 100,
4532
+ severities: 50,
4533
+ monitorStatuses: 50,
4534
+ },
4535
+ },
4536
+ ];
4537
+
4538
+ interface SweepCommand {
4539
+ text: string;
4540
+ heading: string;
4541
+ monitorsChoiceSetId: string;
4542
+ addLaterHint: string;
4543
+ createRoute: string;
4544
+ hasOnCallPolicies: boolean;
4545
+ }
4546
+
4547
+ const COMMANDS: Array<SweepCommand> = [
4548
+ {
4549
+ text: "create incident",
4550
+ heading: "Create New Incident",
4551
+ monitorsChoiceSetId: "incidentMonitors",
4552
+ addLaterHint: INCIDENT_ADD_LATER_HINT,
4553
+ createRoute: "/incidents/create",
4554
+ hasOnCallPolicies: true,
4555
+ },
4556
+ {
4557
+ text: "create maintenance",
4558
+ heading: "Create New Scheduled Maintenance",
4559
+ monitorsChoiceSetId: "scheduledMaintenanceMonitors",
4560
+ addLaterHint: MAINTENANCE_ADD_LATER_HINT,
4561
+ createRoute: "/scheduled-maintenance-events/create",
4562
+ hasOnCallPolicies: false,
4563
+ },
4564
+ ];
4565
+
4566
+ // One delivery of the sweep: the form is accepted at once and fits.
4567
+ async function expectFormFits(data: {
4568
+ command: SweepCommand;
4569
+ shape: ProjectShape;
4570
+ }): Promise<void> {
4571
+ const project: ProjectData = stubProjectData(data.shape);
4572
+ const description: string = `${data.command.text} with ${data.shape.monitors} monitors`;
4573
+
4574
+ const view: TeamsView = viewOf(
4575
+ await deliverLikeTeams(
4576
+ buildMessageActivity({
4577
+ id: nextActivityId("sweep"),
4578
+ text: data.command.text,
4579
+ }),
4580
+ ),
4581
+ );
4582
+
4583
+ expect({ description, httpStatuses: view.httpStatuses }).toEqual({
4584
+ description,
4585
+ httpStatuses: [200],
4586
+ });
4587
+ // Accepted the first time at the documented limit: no 413 at all.
4588
+ expect({ description, refused: view.refused.length }).toEqual({
4589
+ description,
4590
+ refused: 0,
4591
+ });
4592
+ expect({ description, bubbles: labelsOf(view.bubbles) }).toEqual({
4593
+ description,
4594
+ bubbles: [data.command.heading],
4595
+ });
4596
+
4597
+ const form: ConnectorCall = view.bubbles[0]!;
4598
+ expect(form.cardUtf16Bytes).toBeLessThanOrEqual(CARD_BUDGET_IN_UTF16_BYTES);
4599
+
4600
+ const card: JSONObject = cardOf(form);
4601
+ const shownCounts: Array<{ shown: number; total: number }> = [];
4602
+ const lists: Array<CardListExpectation> = [
4603
+ {
4604
+ choiceSetId: data.command.monitorsChoiceSetId,
4605
+ pluralNoun: "monitors",
4606
+ addLaterHint: data.command.addLaterHint,
4607
+ rows: project.monitors,
4608
+ },
4609
+ {
4610
+ choiceSetId: "labels",
4611
+ pluralNoun: "labels",
4612
+ addLaterHint: data.command.addLaterHint,
4613
+ rows: project.labels,
4614
+ },
4615
+ ];
4616
+ if (data.command.hasOnCallPolicies) {
4617
+ lists.push({
4618
+ choiceSetId: "onCallDutyPolicies",
4619
+ pluralNoun: "on-call policies",
4620
+ addLaterHint: data.command.addLaterHint,
4621
+ rows: project.onCallPolicies,
4622
+ });
4623
+ }
4624
+
4625
+ for (const list of lists) {
4626
+ shownCounts.push({
4627
+ shown: expectListAccountedFor(card, list),
4628
+ total: list.rows.length,
4629
+ });
4630
+ }
4631
+
4632
+ const shownMonitors: number = shownCounts[0]!.shown;
4633
+ expect(shownMonitors).toBeLessThanOrEqual(
4634
+ MICROSOFT_TEAMS_MAX_MONITOR_CHOICES,
4635
+ );
4636
+ // Monitor statuses are offered only alongside monitors.
4637
+ expect(getElement(card, "monitorStatus") !== undefined).toBe(
4638
+ shownMonitors > 0,
4639
+ );
4640
+
4641
+ // "Create in OneUptime" exactly when something was left off.
4642
+ const isAnythingLeftOff: boolean = shownCounts.some(
4643
+ (count: { shown: number; total: number }) => {
4644
+ return count.shown < count.total;
4645
+ },
4646
+ );
4647
+ expect(getCreateInOneUptimeActions(card)).toEqual(
4648
+ isAnythingLeftOff
4649
+ ? [
4650
+ {
4651
+ type: "Action.OpenUrl",
4652
+ title: "Create in OneUptime",
4653
+ url: dashboardLink(data.command.createRoute),
4654
+ },
4655
+ ]
4656
+ : [],
4657
+ );
4658
+ }
4659
+
4660
+ for (const command of COMMANDS) {
4661
+ for (const sweepShape of SHAPES) {
4662
+ test(`'${command.text}', ${sweepShape.description}: 0 to 5000 monitors`, async () => {
4663
+ for (const monitorCount of MONITOR_COUNTS) {
4664
+ await expectFormFits({
4665
+ command: command,
4666
+ shape: { ...sweepShape.shape, monitors: monitorCount },
4667
+ });
4668
+ }
4669
+ });
4670
+ }
4671
+ }
4672
+ });