@oneuptime/common 14.0.9 → 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 (383) 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 +2 -0
  9. package/Models/DatabaseModels/PodmanHost.ts +213 -0
  10. package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
  11. package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
  12. package/Models/DatabaseModels/RunnerJob.ts +144 -0
  13. package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
  14. package/Server/API/AutoRemediationAPI.ts +392 -1
  15. package/Server/API/ResourceAiAccessAPI.ts +1669 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  18. package/Server/Infrastructure/Semaphore.ts +22 -0
  19. package/Server/Middleware/TelemetryIngest.ts +15 -5
  20. package/Server/Services/AnalyticsDatabaseService.ts +45 -1
  21. package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
  22. package/Server/Services/CephClusterService.ts +109 -1
  23. package/Server/Services/DatabaseServerService.ts +116 -1
  24. package/Server/Services/DockerHostService.ts +109 -1
  25. package/Server/Services/DockerSwarmClusterService.ts +113 -1
  26. package/Server/Services/HostService.ts +126 -4
  27. package/Server/Services/MetricRecordingRuleService.ts +47 -0
  28. package/Server/Services/PodmanHostService.ts +109 -1
  29. package/Server/Services/ProxmoxClusterService.ts +110 -1
  30. package/Server/Services/ResourceAiAccessService.ts +1439 -0
  31. package/Server/Services/ResourceAiAgentJobService.ts +202 -0
  32. package/Server/Services/ResourceAiAgentService.ts +2432 -0
  33. package/Server/Services/RunnerJobService.ts +611 -4
  34. package/Server/Services/TelemetryUsageBillingService.ts +28 -0
  35. package/Server/Services/TraceRecordingRuleService.ts +33 -0
  36. package/Server/Services/VMwareVCenterService.ts +110 -1
  37. package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1434 -32
  38. package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +935 -21
  39. package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
  40. package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
  41. package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
  42. package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
  43. package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
  44. package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
  45. package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
  46. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +72 -2
  47. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
  48. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
  49. package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +396 -13
  50. package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
  51. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
  52. package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
  53. package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
  54. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
  55. package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
  56. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
  57. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
  58. package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
  59. package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
  60. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +366 -143
  61. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
  62. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
  63. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
  64. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
  65. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
  66. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
  67. package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +93 -0
  68. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
  69. package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
  70. package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
  71. package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
  72. package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
  73. package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
  74. package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
  75. package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
  76. package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
  77. package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
  78. package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
  79. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
  80. package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
  81. package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
  82. package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
  83. package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
  84. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
  85. package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
  86. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
  87. package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +63 -5
  88. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +103 -5
  89. package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +19 -1
  90. package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
  91. package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
  92. package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
  93. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
  94. package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
  95. package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
  96. package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
  97. package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
  98. package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
  99. package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
  100. package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
  101. package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
  102. package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
  103. package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
  104. package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
  105. package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +105 -0
  106. package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
  107. package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
  108. package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
  109. package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
  110. package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
  111. package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
  112. package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
  113. package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
  114. package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
  115. package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
  116. package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
  117. package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
  118. package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
  119. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
  120. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
  121. package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
  122. package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
  123. package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
  124. package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
  125. package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
  126. package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
  127. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
  128. package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +33 -3
  129. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
  130. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
  131. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
  132. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
  133. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
  134. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
  135. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
  136. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
  137. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
  138. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
  139. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
  140. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
  141. package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
  142. package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
  143. package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
  144. package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
  145. package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
  146. package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
  147. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
  148. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
  149. package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
  150. package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
  151. package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
  152. package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
  153. package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
  154. package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
  155. package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
  156. package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
  157. package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
  158. package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
  159. package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
  160. package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
  161. package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
  162. package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
  163. package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
  164. package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
  165. package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
  166. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
  167. package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
  168. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
  169. package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
  170. package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
  171. package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
  172. package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
  173. package/Types/AI/ResourceAiAccessApi.ts +140 -0
  174. package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
  175. package/Types/AutoRemediation/AiRemediationCommandPlan.ts +183 -2
  176. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
  177. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
  178. package/Types/Monitor/RumAlertTemplates.ts +351 -4
  179. package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
  180. package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
  181. package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
  182. package/Types/Runbook/RunbookStepType.ts +29 -0
  183. package/Types/Telemetry/TelemetryIngestSurface.ts +14 -2
  184. package/Utils/AI/InvestigationReport.ts +121 -15
  185. package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
  186. package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
  187. package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
  188. package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
  189. package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
  190. package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
  191. package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
  192. package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
  193. package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
  194. package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
  195. package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
  196. package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
  197. package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
  198. package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
  199. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
  200. package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
  201. package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
  202. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  203. package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
  204. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
  205. package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
  206. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  207. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
  208. package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
  209. package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
  210. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  211. package/build/dist/Models/DatabaseModels/Host.js +219 -0
  212. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  213. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  214. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  215. package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
  216. package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
  217. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
  218. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  219. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
  220. package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
  221. package/build/dist/Models/DatabaseModels/RunnerJob.js +145 -0
  222. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  223. package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
  224. package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
  225. package/build/dist/Server/API/AutoRemediationAPI.js +231 -1
  226. package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
  227. package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
  228. package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
  230. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
  231. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  232. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  233. package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
  234. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  235. package/build/dist/Server/Middleware/TelemetryIngest.js +12 -5
  236. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  237. package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
  238. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  239. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
  240. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  241. package/build/dist/Server/Services/CephClusterService.js +104 -0
  242. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  243. package/build/dist/Server/Services/DatabaseServerService.js +99 -0
  244. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
  245. package/build/dist/Server/Services/DockerHostService.js +104 -0
  246. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  247. package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
  248. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  249. package/build/dist/Server/Services/HostService.js +114 -2
  250. package/build/dist/Server/Services/HostService.js.map +1 -1
  251. package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
  252. package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
  253. package/build/dist/Server/Services/PodmanHostService.js +104 -0
  254. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  255. package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
  256. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  257. package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
  258. package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
  259. package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
  260. package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
  261. package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
  262. package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
  263. package/build/dist/Server/Services/RunnerJobService.js +344 -5
  264. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  265. package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
  266. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  267. package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
  268. package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
  269. package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
  270. package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
  271. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1029 -30
  272. package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
  273. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +633 -28
  274. package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
  275. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
  276. package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
  277. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
  278. package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
  279. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
  280. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
  281. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
  282. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
  283. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
  284. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
  285. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
  286. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
  287. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
  288. package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
  289. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +50 -4
  290. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  291. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
  292. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  293. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
  294. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  295. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +290 -13
  296. package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
  297. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
  298. package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
  299. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
  300. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  301. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
  302. package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
  303. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
  304. package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
  305. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
  306. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  307. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
  308. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
  309. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
  310. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  311. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
  312. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  313. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
  314. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
  315. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
  316. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
  317. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +263 -110
  318. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  319. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
  320. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
  321. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
  322. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
  323. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
  324. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
  325. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
  326. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
  327. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
  328. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
  329. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
  330. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
  331. package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
  332. package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
  333. package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
  334. package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
  335. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +116 -29
  336. package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
  337. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
  338. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  339. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
  340. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  341. package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
  342. package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
  343. package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
  344. package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
  345. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
  346. package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
  347. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
  348. package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
  349. package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
  350. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  351. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +13 -2
  352. package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
  353. package/build/dist/Utils/AI/InvestigationReport.js +71 -11
  354. package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
  355. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
  356. package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
  357. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
  358. package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
  359. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
  360. package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
  361. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
  362. package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
  363. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
  364. package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
  365. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
  366. package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
  367. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
  368. package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
  369. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
  370. package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
  371. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
  372. package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
  373. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
  374. package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
  375. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
  376. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
  377. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
  378. package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
  379. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
  380. package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
  381. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
  382. package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
  383. package/package.json +1 -1
@@ -0,0 +1,2293 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ jest,
7
+ test,
8
+ } from "@jest/globals";
9
+ import type { TurnContext } from "botbuilder";
10
+ import type { SpyInstance } from "jest-mock";
11
+ import DatabaseBaseModel from "../../../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
12
+ import Incident from "../../../../../Models/DatabaseModels/Incident";
13
+ import ScheduledMaintenance from "../../../../../Models/DatabaseModels/ScheduledMaintenance";
14
+ import WorkspaceProjectAuthToken from "../../../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
15
+ import DatabaseConfig from "../../../../../Server/DatabaseConfig";
16
+ import IncidentService from "../../../../../Server/Services/IncidentService";
17
+ import MonitorService from "../../../../../Server/Services/MonitorService";
18
+ import ScheduledMaintenanceService from "../../../../../Server/Services/ScheduledMaintenanceService";
19
+ import TeamMemberService from "../../../../../Server/Services/TeamMemberService";
20
+ import { ProjectScopedReferenceException } from "../../../../../Server/Utils/Database/ProjectScopedReferenceValidator";
21
+ import logger, { LogAttributes } from "../../../../../Server/Utils/Logger";
22
+ import {
23
+ MicrosoftTeamsIncidentActionType,
24
+ MicrosoftTeamsScheduledMaintenanceActionType,
25
+ } from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/ActionTypes";
26
+ import MicrosoftTeamsAuthAction, {
27
+ MicrosoftTeamsRequest,
28
+ } from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/Auth";
29
+ import MicrosoftTeamsIncidentActions from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/Incident";
30
+ import MicrosoftTeamsScheduledMaintenanceActions, {
31
+ MicrosoftTeamsNewScheduledMaintenanceFormChoices,
32
+ } from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance";
33
+ import MicrosoftTeamsUtil, {
34
+ MicrosoftTeamsTenantResolution,
35
+ } from "../../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
36
+ import { MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES } from "../../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize";
37
+ import WorkspaceActionAuthorization from "../../../../../Server/Utils/Workspace/WorkspaceActionAuthorization";
38
+ import WorkspaceProjectReferenceValidator from "../../../../../Server/Utils/Workspace/WorkspaceProjectReferenceValidator";
39
+ import URL from "../../../../../Types/API/URL";
40
+ import DatabaseCommonInteractionProps from "../../../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
41
+ import BadDataException from "../../../../../Types/Exception/BadDataException";
42
+ import NotAuthorizedException from "../../../../../Types/Exception/NotAuthorizedException";
43
+ import { JSONObject } from "../../../../../Types/JSON";
44
+ import ObjectID from "../../../../../Types/ObjectID";
45
+ import Permission, {
46
+ UserPermission,
47
+ UserTenantAccessPermission,
48
+ } from "../../../../../Types/Permission";
49
+
50
+ /*
51
+ * Issue #4111: in Microsoft Teams, "create incident" and "create maintenance"
52
+ * answered "Sorry, I encountered an error processing your request", and every
53
+ * error bubble showed up twice. The forms were too big for Teams (HTTP 413),
54
+ * and a failed turn was rethrown, so Teams got an HTTP 500 and delivered the
55
+ * same message again. The fix also reworked what happens when a form is
56
+ * submitted. These tests pin that half, through the handlers the bot calls
57
+ * for a card submit: handleBotIncidentAction (SubmitNewIncident) and
58
+ * handleBotScheduledMaintenanceAction (SubmitNewScheduledMaintenance).
59
+ *
60
+ * - One answer per submit, and nothing escapes the handler to become a 500
61
+ * and a second bubble. A refused create gets one reply saying why: the
62
+ * BadDataException / NotAuthorizedException message; for a reference the
63
+ * project does not have (another project's id, or a record deleted since
64
+ * the form was sent) a fixed line, because the validator's message names
65
+ * the other project's record; for anything else a generic line with a link
66
+ * to create it in OneUptime instead. The details go to the log: a line
67
+ * that says what failed and why, then the error itself for its stack,
68
+ * which an HTTP error leaves out (its status and code say it all).
69
+ * - A created record is never reported as failed: the confirmation goes
70
+ * first, and removing the submitted form after it is best-effort. A user
71
+ * told "failed" submits again and gets a duplicate (for an incident, its
72
+ * on-call policies are paged twice). A failed create keeps the form.
73
+ * - The chosen monitor status travels on the record (changeMonitorStatusToId),
74
+ * and only with chosen monitors. Nothing writes to a monitor directly.
75
+ * - Maintenance start and end are a bare date and time, read in the zone the
76
+ * form named (what whoever fills it in was told to type in; in a channel
77
+ * that can be someone else's zone), then the submitter's own zone, then the
78
+ * submit's UTC offset, then UTC, never in the server's. They are echoed
79
+ * with the zone used, and a reading at a bare offset says so, since the
80
+ * offset is today's. The end must be after the start, and the start in the
81
+ * future, give or take five minutes: Input.Time has minute precision, so a
82
+ * start picked for "now" that has only just gone by starts now.
83
+ * MicrosoftTeamsCreateSubmitServerTimezone.test.ts repeats the reads on a
84
+ * server that is not on UTC.
85
+ * - Who may create what: a scheduled maintenance event takes the permission
86
+ * the dashboard asks for; an incident takes a linked account that is a
87
+ * member of the project, and no permission beyond that, by design (see the
88
+ * incident's "who may submit it"). Those tests run the real checks on the
89
+ * permissions and memberships a member really holds.
90
+ *
91
+ * "Now" is pinned (only Date is faked). The reference check, the services'
92
+ * create, the dashboard URL and, outside "who may submit it", the permission
93
+ * check are stubbed, so no database is touched.
94
+ */
95
+
96
+ // The clock every test runs at: noon UTC, 08:00 in New York, 17:30 in Kolkata.
97
+ const NOW: Date = new Date("2026-09-29T12:00:00.000Z");
98
+
99
+ const PROJECT_ID: ObjectID = new ObjectID(
100
+ "5d0c1f4e-7a3b-4c8e-9f21-6b7a8c9d0e1f",
101
+ );
102
+ const USER_ID: ObjectID = new ObjectID("8e2a4c6b-1d3f-4a5b-8c7d-9e0f1a2b3c4d");
103
+ const CREATED_ID: ObjectID = new ObjectID(
104
+ "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
105
+ );
106
+ const SEVERITY_ID: string = "59b5ab80-63f6-4ffd-b3df-31c6ad127695";
107
+ const MONITOR_ID: string = "0e7c5d0e-8b44-4b53-9d3c-0f4e7a1b2c01";
108
+ const SECOND_MONITOR_ID: string = "0e7c5d0e-8b44-4b53-9d3c-0f4e7a1b2c02";
109
+ const MONITOR_STATUS_ID: string = "1a1cf3f2-0e35-4a1e-98a1-3f0f8b5f7f9e";
110
+ const LABEL_ID: string = "5f1e2d3c-4b5a-4968-8776-655443322110";
111
+ const ON_CALL_POLICY_ID: string = "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a";
112
+
113
+ // The Microsoft Entra tenant and user a card submit comes from.
114
+ const TEAMS_TENANT_ID: string = "72f988bf-86f1-41af-91ab-2d7cd011db47";
115
+ const TEAMS_USER_AAD_OBJECT_ID: string = "3f2b7c1e-5d4a-4e8b-9c0d-1a2b3c4d5e6f";
116
+
117
+ // The form card the user filled in: the submit activity's replyToId.
118
+ const FORM_ACTIVITY_ID: string = "1727611200123";
119
+
120
+ // The member's own permissions, as getProjectMemberProps resolves them.
121
+ const MEMBER_PROPS: DatabaseCommonInteractionProps = {
122
+ userId: USER_ID,
123
+ tenantId: PROJECT_ID,
124
+ };
125
+
126
+ // What handleBotInvokeActivity hands the scheduled maintenance handler.
127
+ const MAINTENANCE_REQUEST: MicrosoftTeamsRequest = {
128
+ isAuthorized: true,
129
+ projectId: PROJECT_ID,
130
+ authToken: "",
131
+ payloadType: "invoke",
132
+ userId: USER_ID.toString(),
133
+ };
134
+
135
+ const DASHBOARD_URL: string = "https://oneuptime.test/dashboard";
136
+
137
+ const INCIDENT_LINK: URL = URL.fromString(
138
+ `${DASHBOARD_URL}/${PROJECT_ID.toString()}/incidents/${CREATED_ID.toString()}`,
139
+ );
140
+ const MAINTENANCE_LINK: URL = URL.fromString(
141
+ `${DASHBOARD_URL}/${PROJECT_ID.toString()}/scheduled-maintenance-events/${CREATED_ID.toString()}`,
142
+ );
143
+
144
+ // Every failed create is logged with the project it was for.
145
+ const PROJECT_LOG_ATTRIBUTES: LogAttributes = {
146
+ projectId: PROJECT_ID.toString(),
147
+ };
148
+
149
+ /*
150
+ * The fixed text a reference the project does not have is answered with. It
151
+ * names no record: the validator's message names the other project's.
152
+ */
153
+ const UNAVAILABLE_REFERENCE_REASON: string =
154
+ "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.";
155
+
156
+ const INCIDENT_CREATED: string = "✅ Incident created successfully!";
157
+ const INCIDENT_CREATED_WITH_LINK: string = `${INCIDENT_CREATED}\n\nView incident: ${INCIDENT_LINK.toString()}`;
158
+ const INCIDENT_MISSING_FIELDS: string =
159
+ "Unable to create incident: missing required fields (title, description, or severity).";
160
+ const INCIDENT_REFERENCE_UNAVAILABLE: string = `❌ Could not create the incident: ${UNAVAILABLE_REFERENCE_REASON}`;
161
+ const INCIDENT_UNEXPECTED_FAILURE: string = `❌ Could not create the incident because of an unexpected error. Please try again, or create it in OneUptime: ${DASHBOARD_URL}/${PROJECT_ID.toString()}/incidents/create`;
162
+ const INCIDENT_UNEXPECTED_FAILURE_WITHOUT_LINK: string =
163
+ "❌ Could not create the incident because of an unexpected error. Please try again, or create it in OneUptime.";
164
+
165
+ const MAINTENANCE_CREATED: string =
166
+ "✅ Scheduled maintenance created successfully!";
167
+ const MAINTENANCE_MISSING_FIELDS: string =
168
+ "Unable to create scheduled maintenance: missing required fields (title, description, start date/time, or end date/time).";
169
+ const MAINTENANCE_UNREADABLE_TIME: string =
170
+ "Unable to create scheduled maintenance: the start or end date and time could not be read. Please pick them again.";
171
+ const MAINTENANCE_REFERENCE_UNAVAILABLE: string = `❌ Could not create the scheduled maintenance event: ${UNAVAILABLE_REFERENCE_REASON}`;
172
+ const MAINTENANCE_UNEXPECTED_FAILURE: string = `❌ Could not create the scheduled maintenance event because of an unexpected error. Please try again, or create it in OneUptime: ${DASHBOARD_URL}/${PROJECT_ID.toString()}/scheduled-maintenance-events/create`;
173
+ const MAINTENANCE_UNEXPECTED_FAILURE_WITHOUT_LINK: string =
174
+ "❌ Could not create the scheduled maintenance event because of an unexpected error. Please try again, or create it in OneUptime.";
175
+
176
+ type ReplyEvent = { kind: "reply"; text: string };
177
+ type DeleteEvent = { kind: "delete"; activityId: string };
178
+ type TurnEvent = ReplyEvent | DeleteEvent;
179
+
180
+ interface FakeTurn {
181
+ turnContext: TurnContext;
182
+ // Every reply and every delete the handler asked Teams for, in order.
183
+ events: Array<TurnEvent>;
184
+ }
185
+
186
+ /*
187
+ * A TurnContext for one card submit. Teams can be told to refuse every reply
188
+ * (replyError) or the removal of the submitted form (deleteError); a refused
189
+ * call is still recorded, since it was attempted.
190
+ */
191
+ function createFakeTurn(options?: {
192
+ activity?: JSONObject | undefined;
193
+ replyError?: Error | undefined;
194
+ deleteError?: Error | undefined;
195
+ }): FakeTurn {
196
+ const events: Array<TurnEvent> = [];
197
+
198
+ const turnContext: TurnContext = {
199
+ activity: options?.activity || submitActivity(),
200
+ sendActivity: async (reply: unknown): Promise<{ id: string }> => {
201
+ events.push({
202
+ kind: "reply",
203
+ text: typeof reply === "string" ? reply : JSON.stringify(reply),
204
+ });
205
+
206
+ if (options?.replyError) {
207
+ throw options.replyError;
208
+ }
209
+
210
+ return { id: `reply-${events.length}` };
211
+ },
212
+ deleteActivity: async (activityId: string): Promise<void> => {
213
+ events.push({ kind: "delete", activityId: activityId });
214
+
215
+ if (options?.deleteError) {
216
+ throw options.deleteError;
217
+ }
218
+ },
219
+ } as unknown as TurnContext;
220
+
221
+ return { turnContext: turnContext, events: events };
222
+ }
223
+
224
+ // The message activity Teams sends when the form's submit button is pressed.
225
+ function submitActivity(fields?: JSONObject): JSONObject {
226
+ return {
227
+ type: "message",
228
+ id: "1727611260456",
229
+ replyToId: FORM_ACTIVITY_ID,
230
+ ...fields,
231
+ };
232
+ }
233
+
234
+ function repliesOf(turn: FakeTurn): Array<string> {
235
+ return turn.events
236
+ .filter((event: TurnEvent): event is ReplyEvent => {
237
+ return event.kind === "reply";
238
+ })
239
+ .map((event: ReplyEvent) => {
240
+ return event.text;
241
+ });
242
+ }
243
+
244
+ // What botbuilder throws when the Bot Connector refuses a call.
245
+ function teamsRefusal(statusCode: number, code: string): Error {
246
+ return Object.assign(new Error(`Teams answered ${statusCode} ${code}.`), {
247
+ name: "RestError",
248
+ statusCode: statusCode,
249
+ code: code,
250
+ });
251
+ }
252
+
253
+ function relationIds(
254
+ models: Array<DatabaseBaseModel> | undefined,
255
+ ): Array<string> | undefined {
256
+ return models?.map((model: DatabaseBaseModel) => {
257
+ return model.id!.toString();
258
+ });
259
+ }
260
+
261
+ /*
262
+ * The props getProjectMemberProps builds for a member of the project who
263
+ * holds exactly these permissions: what the real permission check reads.
264
+ */
265
+ function memberWith(
266
+ permissions: Array<Permission>,
267
+ ): DatabaseCommonInteractionProps {
268
+ const tenantPermission: UserTenantAccessPermission = {
269
+ _type: "UserTenantAccessPermission",
270
+ projectId: PROJECT_ID,
271
+ permissions: permissions.map((permission: Permission): UserPermission => {
272
+ return {
273
+ _type: "UserPermission",
274
+ permission: permission,
275
+ isBlockPermission: false,
276
+ labelIds: [],
277
+ };
278
+ }),
279
+ };
280
+
281
+ return {
282
+ userId: USER_ID,
283
+ tenantId: PROJECT_ID,
284
+ userTeamIds: [],
285
+ userTenantAccessPermission: {
286
+ [PROJECT_ID.toString()]: tenantPermission,
287
+ },
288
+ };
289
+ }
290
+
291
+ let validateReferencesSpy: SpyInstance<
292
+ typeof WorkspaceProjectReferenceValidator.validateReferencesBelongToProject
293
+ >;
294
+ let assertCanCreateSpy: SpyInstance<
295
+ typeof WorkspaceActionAuthorization.assertCanCreate
296
+ >;
297
+ let monitorUpdateOneBySpy: SpyInstance<typeof MonitorService.updateOneBy>;
298
+ let monitorUpdateOneByIdSpy: SpyInstance<typeof MonitorService.updateOneById>;
299
+ let dashboardUrlSpy: SpyInstance<typeof DatabaseConfig.getDashboardUrl>;
300
+ let errorLogSpy: SpyInstance<typeof logger.error>;
301
+
302
+ beforeEach((): void => {
303
+ // Only Date is faked: "now" is pinned, every real timer keeps running.
304
+ jest.useFakeTimers({
305
+ now: NOW.getTime(),
306
+ doNotFake: [
307
+ "hrtime",
308
+ "nextTick",
309
+ "performance",
310
+ "queueMicrotask",
311
+ "requestAnimationFrame",
312
+ "cancelAnimationFrame",
313
+ "requestIdleCallback",
314
+ "cancelIdleCallback",
315
+ "setImmediate",
316
+ "clearImmediate",
317
+ "setInterval",
318
+ "clearInterval",
319
+ "setTimeout",
320
+ "clearTimeout",
321
+ ],
322
+ });
323
+
324
+ // The references themselves are pinned in WorkspaceCreateProjectReferences.test.ts.
325
+ validateReferencesSpy = jest
326
+ .spyOn(
327
+ WorkspaceProjectReferenceValidator,
328
+ "validateReferencesBelongToProject",
329
+ )
330
+ .mockResolvedValue();
331
+
332
+ /*
333
+ * The permission rules are pinned in the workspace authorization tests;
334
+ * "who may submit it" below restores the real check.
335
+ */
336
+ assertCanCreateSpy = jest
337
+ .spyOn(WorkspaceActionAuthorization, "assertCanCreate")
338
+ .mockResolvedValue();
339
+
340
+ // Stubbed so a regression that writes monitors directly fails, not hangs.
341
+ monitorUpdateOneBySpy = jest
342
+ .spyOn(MonitorService, "updateOneBy")
343
+ .mockResolvedValue(1);
344
+ monitorUpdateOneByIdSpy = jest
345
+ .spyOn(MonitorService, "updateOneById")
346
+ .mockResolvedValue(1);
347
+
348
+ // Where a failed create points the user to instead.
349
+ dashboardUrlSpy = jest
350
+ .spyOn(DatabaseConfig, "getDashboardUrl")
351
+ .mockResolvedValue(URL.fromString(DASHBOARD_URL));
352
+
353
+ errorLogSpy = jest.spyOn(logger, "error").mockImplementation((): void => {});
354
+ });
355
+
356
+ afterEach((): void => {
357
+ jest.useRealTimers();
358
+ jest.restoreAllMocks();
359
+ });
360
+
361
+ function expectNoDirectMonitorWrites(): void {
362
+ expect(monitorUpdateOneBySpy).not.toHaveBeenCalled();
363
+ expect(monitorUpdateOneByIdSpy).not.toHaveBeenCalled();
364
+ }
365
+
366
+ /*
367
+ * The error log a failed create leaves, and nothing else: a line that says
368
+ * what failed and why, then (errorObjectLogged) the error itself, for its
369
+ * stack and class.
370
+ */
371
+ function expectOnlyCreateFailureLogged(data: {
372
+ summary: string;
373
+ error: unknown;
374
+ errorObjectLogged: boolean;
375
+ }): void {
376
+ expect(errorLogSpy).toHaveBeenCalledTimes(data.errorObjectLogged ? 2 : 1);
377
+ expect(errorLogSpy).toHaveBeenNthCalledWith(
378
+ 1,
379
+ data.summary,
380
+ PROJECT_LOG_ATTRIBUTES,
381
+ );
382
+
383
+ if (data.errorObjectLogged) {
384
+ expect(errorLogSpy.mock.calls[1]![0]).toBe(data.error);
385
+ expect(errorLogSpy.mock.calls[1]![1]).toEqual(PROJECT_LOG_ATTRIBUTES);
386
+ }
387
+ }
388
+
389
+ interface NonMemberSubmit {
390
+ turn: FakeTurn;
391
+ // TeamMember, as the real membership check reads it.
392
+ membershipLookup: SpyInstance<typeof TeamMemberService.findBy>;
393
+ }
394
+
395
+ /*
396
+ * A card submit handed to handleBotInvokeActivity, as the bot hands it every
397
+ * card submit, from a linked Teams account whose OneUptime user holds no
398
+ * accepted membership in the project (removed from it, say). The tenant and
399
+ * account lookups are stubbed; the membership check is the real one, reading
400
+ * TeamMember, which answers with no rows.
401
+ */
402
+ async function submitAsLinkedNonMember(
403
+ value: JSONObject,
404
+ ): Promise<NonMemberSubmit> {
405
+ const tenantResolution: MicrosoftTeamsTenantResolution = {
406
+ projectAuth: {
407
+ projectId: PROJECT_ID,
408
+ } as unknown as WorkspaceProjectAuthToken,
409
+ isAmbiguous: false,
410
+ candidateProjectIds: [PROJECT_ID],
411
+ };
412
+
413
+ jest
414
+ .spyOn(MicrosoftTeamsUtil, "resolveProjectByTenantId")
415
+ .mockResolvedValue(tenantResolution);
416
+ jest
417
+ .spyOn(MicrosoftTeamsAuthAction, "getOneUptimeUserIdFromTeamsUserId")
418
+ .mockResolvedValue(USER_ID);
419
+ const membershipLookup: SpyInstance<typeof TeamMemberService.findBy> = jest
420
+ .spyOn(TeamMemberService, "findBy")
421
+ .mockResolvedValue([]);
422
+
423
+ const activity: JSONObject = submitActivity({
424
+ from: { id: "29:1Hk8-teams-user", aadObjectId: TEAMS_USER_AAD_OBJECT_ID },
425
+ conversation: { conversationType: "personal", id: "a:1personal-chat" },
426
+ channelData: { tenant: { id: TEAMS_TENANT_ID } },
427
+ value: value,
428
+ });
429
+ const turn: FakeTurn = createFakeTurn({ activity: activity });
430
+
431
+ await expect(
432
+ MicrosoftTeamsUtil.handleBotInvokeActivity({
433
+ activity: activity,
434
+ turnContext: turn.turnContext,
435
+ }),
436
+ ).resolves.toBeUndefined();
437
+
438
+ return { turn: turn, membershipLookup: membershipLookup };
439
+ }
440
+
441
+ /*
442
+ * The refusal a non-member gets: told why, once, after the real membership
443
+ * lookup for this user in this project.
444
+ */
445
+ function expectRefusedAsNonMember(submitted: NonMemberSubmit): void {
446
+ expect(submitted.membershipLookup).toHaveBeenCalledTimes(1);
447
+ expect(submitted.membershipLookup.mock.calls[0]![0].query).toEqual({
448
+ userId: USER_ID,
449
+ projectId: PROJECT_ID,
450
+ hasAcceptedInvitation: true,
451
+ });
452
+ expect(submitted.turn.events).toEqual([
453
+ {
454
+ kind: "reply",
455
+ text: "Your OneUptime account is not a member of this project. Ask a project admin to invite you, then try again.",
456
+ },
457
+ ]);
458
+ }
459
+
460
+ describe("Microsoft Teams: submitting the Create New Incident form", (): void => {
461
+ let createSpy: SpyInstance<typeof IncidentService.create>;
462
+ let linkSpy: SpyInstance<typeof IncidentService.getIncidentLinkInDashboard>;
463
+
464
+ function createdIncident(): Incident {
465
+ const incident: Incident = new Incident();
466
+ incident.id = CREATED_ID;
467
+ incident.projectId = PROJECT_ID;
468
+ return incident;
469
+ }
470
+
471
+ function incidentForm(fields?: JSONObject): JSONObject {
472
+ return {
473
+ action: MicrosoftTeamsIncidentActionType.SubmitNewIncident,
474
+ incidentTitle: "Checkout API returns 502",
475
+ incidentDescription: "Every POST /checkout has failed since 09:12.",
476
+ incidentSeverity: SEVERITY_ID,
477
+ ...fields,
478
+ };
479
+ }
480
+
481
+ function submitIncident(
482
+ turn: FakeTurn,
483
+ value: JSONObject,
484
+ databaseProps?: DatabaseCommonInteractionProps,
485
+ ): Promise<void> {
486
+ return MicrosoftTeamsIncidentActions.handleBotIncidentAction({
487
+ actionType: MicrosoftTeamsIncidentActionType.SubmitNewIncident,
488
+ actionValue: "",
489
+ value: value,
490
+ projectId: PROJECT_ID,
491
+ oneUptimeUserId: USER_ID,
492
+ databaseProps: databaseProps || MEMBER_PROPS,
493
+ turnContext: turn.turnContext,
494
+ });
495
+ }
496
+
497
+ // The one incident handed to IncidentService.create.
498
+ function incidentPassedToCreate(): Incident {
499
+ expect(createSpy).toHaveBeenCalledTimes(1);
500
+ return createSpy.mock.calls[0]![0].data;
501
+ }
502
+
503
+ beforeEach((): void => {
504
+ createSpy = jest
505
+ .spyOn(IncidentService, "create")
506
+ .mockResolvedValue(createdIncident());
507
+ linkSpy = jest
508
+ .spyOn(IncidentService, "getIncidentLinkInDashboard")
509
+ .mockResolvedValue(INCIDENT_LINK);
510
+ });
511
+
512
+ test("creates the incident as root from the trimmed form, in the project, as the Teams user", async (): Promise<void> => {
513
+ await submitIncident(
514
+ createFakeTurn(),
515
+ incidentForm({
516
+ incidentTitle: " Checkout API returns 502 \n",
517
+ incidentDescription:
518
+ "\n\t Every POST /checkout has failed since 09:12. ",
519
+ }),
520
+ );
521
+
522
+ expect(createSpy.mock.calls[0]![0].props).toEqual({ isRoot: true });
523
+
524
+ const incident: Incident = incidentPassedToCreate();
525
+ expect(incident.title).toBe("Checkout API returns 502");
526
+ expect(incident.description).toBe(
527
+ "Every POST /checkout has failed since 09:12.",
528
+ );
529
+ expect(incident.projectId?.toString()).toBe(PROJECT_ID.toString());
530
+ expect(incident.createdByUserId?.toString()).toBe(USER_ID.toString());
531
+ expect(incident.incidentSeverityId?.toString()).toBe(SEVERITY_ID);
532
+ expect(incident.rootCause).toBe("Incident created via Microsoft Teams");
533
+ });
534
+
535
+ test.each([
536
+ { name: "no title", fields: { incidentTitle: undefined } },
537
+ { name: "a title of only spaces", fields: { incidentTitle: " " } },
538
+ { name: "no description", fields: { incidentDescription: "" } },
539
+ {
540
+ name: "a description of only line breaks",
541
+ fields: { incidentDescription: "\n \n\t" },
542
+ },
543
+ { name: "no severity", fields: { incidentSeverity: "" } },
544
+ ])(
545
+ "refuses $name with the missing-fields message, creates nothing and keeps the form",
546
+ async (row: { name: string; fields: JSONObject }): Promise<void> => {
547
+ const turn: FakeTurn = createFakeTurn();
548
+
549
+ await submitIncident(turn, incidentForm(row.fields));
550
+
551
+ expect(turn.events).toEqual([
552
+ { kind: "reply", text: INCIDENT_MISSING_FIELDS },
553
+ ]);
554
+ expect(validateReferencesSpy).not.toHaveBeenCalled();
555
+ expect(createSpy).not.toHaveBeenCalled();
556
+ },
557
+ );
558
+
559
+ test("hands the chosen monitor status to IncidentService with the chosen monitors, and writes no monitor itself", async (): Promise<void> => {
560
+ await submitIncident(
561
+ createFakeTurn(),
562
+ incidentForm({
563
+ incidentMonitors: `${MONITOR_ID}, ${SECOND_MONITOR_ID}`,
564
+ monitorStatus: MONITOR_STATUS_ID,
565
+ labels: LABEL_ID,
566
+ onCallDutyPolicies: ON_CALL_POLICY_ID,
567
+ }),
568
+ );
569
+
570
+ const incident: Incident = incidentPassedToCreate();
571
+ expect(incident.changeMonitorStatusToId?.toString()).toBe(
572
+ MONITOR_STATUS_ID,
573
+ );
574
+ expect(relationIds(incident.monitors)).toEqual([
575
+ MONITOR_ID,
576
+ SECOND_MONITOR_ID,
577
+ ]);
578
+ expect(relationIds(incident.labels)).toEqual([LABEL_ID]);
579
+ expect(relationIds(incident.onCallDutyPolicies)).toEqual([
580
+ ON_CALL_POLICY_ID,
581
+ ]);
582
+
583
+ // Every submitted id is checked against the project before the create.
584
+ expect(validateReferencesSpy).toHaveBeenCalledTimes(1);
585
+ expect(validateReferencesSpy.mock.invocationCallOrder[0]!).toBeLessThan(
586
+ createSpy.mock.invocationCallOrder[0]!,
587
+ );
588
+ expect(validateReferencesSpy.mock.calls[0]![0]).toEqual({
589
+ projectId: PROJECT_ID,
590
+ subject: "incident",
591
+ monitorIds: [new ObjectID(MONITOR_ID), new ObjectID(SECOND_MONITOR_ID)],
592
+ labelIds: [new ObjectID(LABEL_ID)],
593
+ onCallDutyPolicyIds: [new ObjectID(ON_CALL_POLICY_ID)],
594
+ monitorStatusId: new ObjectID(MONITOR_STATUS_ID),
595
+ });
596
+
597
+ expectNoDirectMonitorWrites();
598
+ });
599
+
600
+ test("drops a monitor status submitted without monitors: there is nothing for it to change", async (): Promise<void> => {
601
+ await submitIncident(
602
+ createFakeTurn(),
603
+ incidentForm({ incidentMonitors: "", monitorStatus: MONITOR_STATUS_ID }),
604
+ );
605
+
606
+ const incident: Incident = incidentPassedToCreate();
607
+ expect(incident.changeMonitorStatusToId).toBeUndefined();
608
+ expect(incident.monitors).toBeUndefined();
609
+ expect(
610
+ validateReferencesSpy.mock.calls[0]![0].monitorStatusId,
611
+ ).toBeUndefined();
612
+ expectNoDirectMonitorWrites();
613
+ });
614
+
615
+ test("asks for no status change when monitors are chosen without a status", async (): Promise<void> => {
616
+ await submitIncident(
617
+ createFakeTurn(),
618
+ incidentForm({ incidentMonitors: MONITOR_ID }),
619
+ );
620
+
621
+ const incident: Incident = incidentPassedToCreate();
622
+ expect(relationIds(incident.monitors)).toEqual([MONITOR_ID]);
623
+ expect(incident.changeMonitorStatusToId).toBeUndefined();
624
+ expectNoDirectMonitorWrites();
625
+ });
626
+
627
+ test.each([
628
+ {
629
+ name: "IncidentService.create refusing with a BadDataException",
630
+ where: "create",
631
+ error: new BadDataException("Incident severity not found in project."),
632
+ reply:
633
+ "❌ Could not create the incident: Incident severity not found in project.",
634
+ logged: "BadDataException: Incident severity not found in project.",
635
+ },
636
+ {
637
+ name: "IncidentService.create refusing with a NotAuthorizedException",
638
+ where: "create",
639
+ error: new NotAuthorizedException(
640
+ "You do not have permission to create an incident.",
641
+ ),
642
+ reply:
643
+ "❌ Could not create the incident: You do not have permission to create an incident.",
644
+ logged:
645
+ "NotAuthorizedException: You do not have permission to create an incident.",
646
+ },
647
+ {
648
+ name: "the reference check refusing another project's monitor",
649
+ where: "validator",
650
+ error: new ProjectScopedReferenceException(
651
+ 'This incident references records that belong to a different project: Monitor "Payroll API". Please pick values from this project and try again.',
652
+ ),
653
+ reply: INCIDENT_REFERENCE_UNAVAILABLE,
654
+ logged:
655
+ 'ProjectScopedReferenceException: This incident references records that belong to a different project: Monitor "Payroll API". Please pick values from this project and try again.',
656
+ },
657
+ {
658
+ // Deleted after the form was sent; IncidentService.create checks it.
659
+ name: "IncidentService.create refusing a severity the project no longer has",
660
+ where: "create",
661
+ error: new ProjectScopedReferenceException(
662
+ `This incident references records that do not exist: Incident Severity "${SEVERITY_ID}". Please pick values that exist in this project and try again.`,
663
+ ),
664
+ reply: INCIDENT_REFERENCE_UNAVAILABLE,
665
+ logged: `ProjectScopedReferenceException: This incident references records that do not exist: Incident Severity "${SEVERITY_ID}". Please pick values that exist in this project and try again.`,
666
+ },
667
+ {
668
+ // Never "❌ Could not create the incident: " with nothing after it.
669
+ name: "a BadDataException without a message",
670
+ where: "create",
671
+ error: new BadDataException(""),
672
+ reply: INCIDENT_UNEXPECTED_FAILURE,
673
+ logged: "BadDataException: ",
674
+ },
675
+ ])(
676
+ "answers $name once, logs it, and leaves the form for a corrected submit",
677
+ async (row: {
678
+ name: string;
679
+ where: string;
680
+ error: Error;
681
+ reply: string;
682
+ logged: string;
683
+ }): Promise<void> => {
684
+ if (row.where === "validator") {
685
+ validateReferencesSpy.mockRejectedValue(row.error);
686
+ } else {
687
+ createSpy.mockRejectedValue(row.error);
688
+ }
689
+ const turn: FakeTurn = createFakeTurn();
690
+
691
+ await expect(
692
+ submitIncident(turn, incidentForm({ incidentMonitors: MONITOR_ID })),
693
+ ).resolves.toBeUndefined();
694
+
695
+ expect(turn.events).toEqual([{ kind: "reply", text: row.reply }]);
696
+ // A refused reference check stops the create; nothing to link to.
697
+ expect(createSpy).toHaveBeenCalledTimes(row.where === "create" ? 1 : 0);
698
+ expect(linkSpy).not.toHaveBeenCalled();
699
+ // Named by class: OneUptime's exceptions all carry Error's own name.
700
+ expectOnlyCreateFailureLogged({
701
+ summary: `Could not create an incident from Microsoft Teams: ${row.logged}`,
702
+ error: row.error,
703
+ errorObjectLogged: true,
704
+ });
705
+ },
706
+ );
707
+
708
+ test("a reference the project does not have is answered without naming the record; the log keeps which one it was", async (): Promise<void> => {
709
+ const validatorMessage: string =
710
+ 'This incident references records that belong to a different project: Monitor "Acme Corp payroll API", On-Call Policy "Acme Corp executives". Please pick values from this project and try again.';
711
+ validateReferencesSpy.mockRejectedValue(
712
+ new ProjectScopedReferenceException(validatorMessage),
713
+ );
714
+ const turn: FakeTurn = createFakeTurn();
715
+
716
+ await submitIncident(
717
+ turn,
718
+ incidentForm({
719
+ incidentMonitors: MONITOR_ID,
720
+ onCallDutyPolicies: ON_CALL_POLICY_ID,
721
+ }),
722
+ );
723
+
724
+ expect(repliesOf(turn)).toEqual([INCIDENT_REFERENCE_UNAVAILABLE]);
725
+ for (const named of ["Acme", "payroll", "executives", MONITOR_ID]) {
726
+ expect(repliesOf(turn)[0]).not.toContain(named);
727
+ }
728
+ expect(createSpy).not.toHaveBeenCalled();
729
+ expect(errorLogSpy.mock.calls[0]![0]).toBe(
730
+ `Could not create an incident from Microsoft Teams: ProjectScopedReferenceException: ${validatorMessage}`,
731
+ );
732
+ });
733
+
734
+ test("answers an unexpected create failure once, with the generic line and where to create it instead; the details go to the log only", async (): Promise<void> => {
735
+ const failure: Error = new Error("connect ECONNREFUSED 10.0.0.5:5432");
736
+ createSpy.mockRejectedValue(failure);
737
+ const turn: FakeTurn = createFakeTurn();
738
+
739
+ await expect(submitIncident(turn, incidentForm())).resolves.toBeUndefined();
740
+
741
+ expect(turn.events).toEqual([
742
+ { kind: "reply", text: INCIDENT_UNEXPECTED_FAILURE },
743
+ ]);
744
+ expectOnlyCreateFailureLogged({
745
+ summary:
746
+ "Could not create an incident from Microsoft Teams: Error: connect ECONNREFUSED 10.0.0.5:5432",
747
+ error: failure,
748
+ errorObjectLogged: true,
749
+ });
750
+ });
751
+
752
+ test("the generic line ends with a full stop when no dashboard link can be built", async (): Promise<void> => {
753
+ dashboardUrlSpy.mockRejectedValue(new Error("GlobalConfig is not ready"));
754
+ createSpy.mockRejectedValue(
755
+ new Error("connect ECONNREFUSED 10.0.0.5:5432"),
756
+ );
757
+ const turn: FakeTurn = createFakeTurn();
758
+
759
+ await expect(submitIncident(turn, incidentForm())).resolves.toBeUndefined();
760
+
761
+ expect(turn.events).toEqual([
762
+ { kind: "reply", text: INCIDENT_UNEXPECTED_FAILURE_WITHOUT_LINK },
763
+ ]);
764
+ });
765
+
766
+ test("a failure that carries an HTTP status is logged in one line: its status and code say it all", async (): Promise<void> => {
767
+ // A call to another service that failed while the incident was created.
768
+ const failure: Error = teamsRefusal(503, "ServiceUnavailable");
769
+ createSpy.mockRejectedValue(failure);
770
+ const turn: FakeTurn = createFakeTurn();
771
+
772
+ await expect(submitIncident(turn, incidentForm())).resolves.toBeUndefined();
773
+
774
+ expect(turn.events).toEqual([
775
+ { kind: "reply", text: INCIDENT_UNEXPECTED_FAILURE },
776
+ ]);
777
+ expectOnlyCreateFailureLogged({
778
+ summary:
779
+ "Could not create an incident from Microsoft Teams: RestError 503 ServiceUnavailable: Teams answered 503 ServiceUnavailable.",
780
+ error: failure,
781
+ errorObjectLogged: false,
782
+ });
783
+ });
784
+
785
+ test("a failure reply Teams refuses does not escape: no HTTP 500, so no redelivered second attempt", async (): Promise<void> => {
786
+ createSpy.mockRejectedValue(new BadDataException("Title is too long."));
787
+ const turn: FakeTurn = createFakeTurn({
788
+ replyError: teamsRefusal(502, "BadGateway"),
789
+ });
790
+
791
+ await expect(submitIncident(turn, incidentForm())).resolves.toBeUndefined();
792
+
793
+ // One attempt, and no second, different answer in its place.
794
+ expect(turn.events).toEqual([
795
+ {
796
+ kind: "reply",
797
+ text: "❌ Could not create the incident: Title is too long.",
798
+ },
799
+ ]);
800
+ expect(createSpy).toHaveBeenCalledTimes(1);
801
+ });
802
+
803
+ test("confirms first, with a link to the incident, and only then removes the submitted form", async (): Promise<void> => {
804
+ const turn: FakeTurn = createFakeTurn();
805
+
806
+ await submitIncident(turn, incidentForm());
807
+
808
+ expect(turn.events).toEqual([
809
+ { kind: "reply", text: INCIDENT_CREATED_WITH_LINK },
810
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
811
+ ]);
812
+ expect(linkSpy).toHaveBeenCalledTimes(1);
813
+ expect(linkSpy.mock.calls[0]![0].toString()).toBe(PROJECT_ID.toString());
814
+ expect(linkSpy.mock.calls[0]![1].toString()).toBe(CREATED_ID.toString());
815
+ });
816
+
817
+ test("a form Teams will not remove leaves just the confirmation: no failure text, nothing thrown", async (): Promise<void> => {
818
+ const turn: FakeTurn = createFakeTurn({
819
+ deleteError: teamsRefusal(403, "Forbidden"),
820
+ });
821
+
822
+ await expect(submitIncident(turn, incidentForm())).resolves.toBeUndefined();
823
+
824
+ expect(turn.events).toEqual([
825
+ { kind: "reply", text: INCIDENT_CREATED_WITH_LINK },
826
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
827
+ ]);
828
+ expect(createSpy).toHaveBeenCalledTimes(1);
829
+ });
830
+
831
+ test("a link that cannot be built still confirms, without the link", async (): Promise<void> => {
832
+ linkSpy.mockRejectedValue(new Error("DashboardUrl is not configured"));
833
+ const turn: FakeTurn = createFakeTurn();
834
+
835
+ await submitIncident(turn, incidentForm());
836
+
837
+ expect(turn.events).toEqual([
838
+ { kind: "reply", text: INCIDENT_CREATED },
839
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
840
+ ]);
841
+ });
842
+
843
+ test("a created incident without an id is confirmed without a link", async (): Promise<void> => {
844
+ createSpy.mockResolvedValue(new Incident());
845
+ const turn: FakeTurn = createFakeTurn();
846
+
847
+ await submitIncident(turn, incidentForm());
848
+
849
+ expect(turn.events).toEqual([
850
+ { kind: "reply", text: INCIDENT_CREATED },
851
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
852
+ ]);
853
+ expect(linkSpy).not.toHaveBeenCalled();
854
+ });
855
+
856
+ test("a submit without a replyToId is confirmed, and nothing is removed", async (): Promise<void> => {
857
+ const turn: FakeTurn = createFakeTurn({
858
+ activity: submitActivity({ replyToId: undefined }),
859
+ });
860
+
861
+ await submitIncident(turn, incidentForm());
862
+
863
+ expect(turn.events).toEqual([
864
+ { kind: "reply", text: INCIDENT_CREATED_WITH_LINK },
865
+ ]);
866
+ });
867
+
868
+ test("a confirmation Teams refuses is not followed by a failure reply, and the form still goes", async (): Promise<void> => {
869
+ const turn: FakeTurn = createFakeTurn({
870
+ replyError: teamsRefusal(429, "TooManyRequests"),
871
+ });
872
+
873
+ await expect(submitIncident(turn, incidentForm())).resolves.toBeUndefined();
874
+
875
+ /*
876
+ * The incident exists. A form left in place with no confirmation under it
877
+ * invites a second press: a second incident, a second page.
878
+ */
879
+ expect(createSpy).toHaveBeenCalledTimes(1);
880
+ expect(turn.events).toEqual([
881
+ { kind: "reply", text: INCIDENT_CREATED_WITH_LINK },
882
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
883
+ ]);
884
+ });
885
+
886
+ describe("who may submit it", (): void => {
887
+ /*
888
+ * Deliberate, not a gap: creating an incident from Teams takes a linked
889
+ * account that is a current member of the project, and no permission
890
+ * beyond that. It is the product decision Slack documents ("anyone in the
891
+ * company can create incident", on SubmitNewIncident in
892
+ * Server/Utils/Workspace/Slack/Actions/Auth.ts), and the scope of commit
893
+ * 7ac7a3a03f, which put every other chat write (acknowledge, resolve,
894
+ * notes, on-call pages, scheduled maintenance) behind the member's own
895
+ * permissions and left this one open. A read-only member can therefore
896
+ * declare an incident, and page the on-call policies it names, from
897
+ * Teams. Changing that is a product decision for both chat integrations,
898
+ * not a fix to make in this handler alone.
899
+ *
900
+ * Membership is checked by handleBotInvokeActivity, through
901
+ * WorkspaceActionAuthorization.getProjectMemberProps, before this handler
902
+ * runs; the last test here goes through it. The real permission check is
903
+ * restored, so a change of policy fails these tests instead of slipping
904
+ * in unnoticed.
905
+ */
906
+ let assertCanCreateCalls: SpyInstance<
907
+ typeof WorkspaceActionAuthorization.assertCanCreate
908
+ >;
909
+
910
+ beforeEach((): void => {
911
+ assertCanCreateSpy.mockRestore();
912
+ // The real check, watched.
913
+ assertCanCreateCalls = jest.spyOn(
914
+ WorkspaceActionAuthorization,
915
+ "assertCanCreate",
916
+ );
917
+ });
918
+
919
+ test.each([
920
+ {
921
+ name: "with an on-call policy chosen",
922
+ fields: { onCallDutyPolicies: ON_CALL_POLICY_ID },
923
+ onCallPolicyIds: [ON_CALL_POLICY_ID],
924
+ },
925
+ { name: "with nothing optional chosen", fields: {}, onCallPolicyIds: [] },
926
+ ])(
927
+ "a linked member without Create Incident permission (a read-only Viewer) still creates one $name: by design",
928
+ async (row: {
929
+ name: string;
930
+ fields: JSONObject;
931
+ onCallPolicyIds: Array<string>;
932
+ }): Promise<void> => {
933
+ const turn: FakeTurn = createFakeTurn();
934
+
935
+ await expect(
936
+ submitIncident(
937
+ turn,
938
+ incidentForm(row.fields),
939
+ memberWith([Permission.Viewer]),
940
+ ),
941
+ ).resolves.toBeUndefined();
942
+
943
+ // IncidentService.create is what pages the chosen policies.
944
+ const incident: Incident = incidentPassedToCreate();
945
+ expect(relationIds(incident.onCallDutyPolicies) || []).toEqual(
946
+ row.onCallPolicyIds,
947
+ );
948
+ expect(assertCanCreateCalls).not.toHaveBeenCalled();
949
+ expect(turn.events).toEqual([
950
+ { kind: "reply", text: INCIDENT_CREATED_WITH_LINK },
951
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
952
+ ]);
953
+ },
954
+ );
955
+
956
+ test("a member whose only permission is Create Incident creates one", async (): Promise<void> => {
957
+ const turn: FakeTurn = createFakeTurn();
958
+
959
+ await submitIncident(
960
+ turn,
961
+ incidentForm(),
962
+ memberWith([Permission.CreateProjectIncident]),
963
+ );
964
+
965
+ expect(incidentPassedToCreate().title).toBe("Checkout API returns 502");
966
+ expect(turn.events).toEqual([
967
+ { kind: "reply", text: INCIDENT_CREATED_WITH_LINK },
968
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
969
+ ]);
970
+ });
971
+
972
+ test("a linked account that is not a member of the project is refused before the form is read: told why, once, and nothing is created or paged", async (): Promise<void> => {
973
+ const handlerSpy: SpyInstance<
974
+ typeof MicrosoftTeamsIncidentActions.handleBotIncidentAction
975
+ > = jest.spyOn(MicrosoftTeamsIncidentActions, "handleBotIncidentAction");
976
+
977
+ const submitted: NonMemberSubmit = await submitAsLinkedNonMember(
978
+ incidentForm({ onCallDutyPolicies: ON_CALL_POLICY_ID }),
979
+ );
980
+
981
+ expectRefusedAsNonMember(submitted);
982
+ expect(handlerSpy).not.toHaveBeenCalled();
983
+ expect(validateReferencesSpy).not.toHaveBeenCalled();
984
+ expect(createSpy).not.toHaveBeenCalled();
985
+ // A refusal written for the user, not a fault for an operator.
986
+ expect(errorLogSpy).not.toHaveBeenCalled();
987
+ });
988
+ });
989
+ });
990
+
991
+ describe("Microsoft Teams: submitting the Create New Scheduled Maintenance form", (): void => {
992
+ let createSpy: SpyInstance<typeof ScheduledMaintenanceService.create>;
993
+ let linkSpy: SpyInstance<
994
+ typeof ScheduledMaintenanceService.getScheduledMaintenanceLinkInDashboard
995
+ >;
996
+
997
+ function createdScheduledMaintenance(): ScheduledMaintenance {
998
+ const scheduledMaintenance: ScheduledMaintenance =
999
+ new ScheduledMaintenance();
1000
+ scheduledMaintenance.id = CREATED_ID;
1001
+ scheduledMaintenance.projectId = PROJECT_ID;
1002
+ return scheduledMaintenance;
1003
+ }
1004
+
1005
+ // What Teams submits: the card's inputs merged with its Action.Submit data.
1006
+ function maintenanceForm(fields?: JSONObject): JSONObject {
1007
+ return {
1008
+ action:
1009
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
1010
+ scheduledMaintenanceTitle: "Primary database upgrade",
1011
+ scheduledMaintenanceDescription:
1012
+ "Postgres 16 to 17. Writes pause for a few minutes.",
1013
+ startDate: "2030-10-01",
1014
+ startTime: "14:00",
1015
+ endDate: "2030-10-01",
1016
+ endTime: "15:00",
1017
+ ...fields,
1018
+ };
1019
+ }
1020
+
1021
+ function submitMaintenance(
1022
+ turn: FakeTurn,
1023
+ value: JSONObject,
1024
+ options?: {
1025
+ request?: MicrosoftTeamsRequest | undefined;
1026
+ databaseProps?: DatabaseCommonInteractionProps | undefined;
1027
+ },
1028
+ ): Promise<void> {
1029
+ return MicrosoftTeamsScheduledMaintenanceActions.handleBotScheduledMaintenanceAction(
1030
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
1031
+ turn.turnContext,
1032
+ value,
1033
+ options?.request || MAINTENANCE_REQUEST,
1034
+ options?.databaseProps || MEMBER_PROPS,
1035
+ );
1036
+ }
1037
+
1038
+ // The one event handed to ScheduledMaintenanceService.create.
1039
+ function eventPassedToCreate(): ScheduledMaintenance {
1040
+ expect(createSpy).toHaveBeenCalledTimes(1);
1041
+ return createSpy.mock.calls[0]![0].data;
1042
+ }
1043
+
1044
+ /*
1045
+ * The confirmation. offsetLabel: the times were read at a bare UTC offset,
1046
+ * which the confirmation owns up to.
1047
+ */
1048
+ function confirmation(data: {
1049
+ starts: string;
1050
+ ends: string;
1051
+ offsetLabel?: string | undefined;
1052
+ link?: URL | undefined;
1053
+ }): string {
1054
+ const offsetNote: string = data.offsetLabel
1055
+ ? `\n\nMicrosoft Teams did not say which time zone you are in, so these times were read at your current offset, ${data.offsetLabel}. If daylight saving time changes before then, check them in OneUptime.`
1056
+ : "";
1057
+ const link: string = data.link
1058
+ ? `\n\nView scheduled maintenance: ${data.link.toString()}`
1059
+ : "";
1060
+
1061
+ return `${MAINTENANCE_CREATED}\n\n**Starts:** ${data.starts}\n\n**Ends:** ${data.ends}${offsetNote}${link}`;
1062
+ }
1063
+
1064
+ // The confirmation for the default form, submitted from New York.
1065
+ const NEW_YORK_CONFIRMATION: string = confirmation({
1066
+ starts: "Oct 1, 2030, 14:00 (America/New_York)",
1067
+ ends: "Oct 1, 2030, 15:00 (America/New_York)",
1068
+ link: MAINTENANCE_LINK,
1069
+ });
1070
+
1071
+ // The confirmation for the default form, read in Asia/Kolkata.
1072
+ const KOLKATA_CONFIRMATION: string = confirmation({
1073
+ starts: "Oct 1, 2030, 14:00 (Asia/Kolkata)",
1074
+ ends: "Oct 1, 2030, 15:00 (Asia/Kolkata)",
1075
+ link: MAINTENANCE_LINK,
1076
+ });
1077
+
1078
+ function inNewYork(fields?: JSONObject): FakeTurn {
1079
+ return createFakeTurn({
1080
+ activity: submitActivity({
1081
+ localTimezone: "America/New_York",
1082
+ ...fields,
1083
+ }),
1084
+ });
1085
+ }
1086
+
1087
+ beforeEach((): void => {
1088
+ createSpy = jest
1089
+ .spyOn(ScheduledMaintenanceService, "create")
1090
+ .mockResolvedValue(createdScheduledMaintenance());
1091
+ linkSpy = jest
1092
+ .spyOn(
1093
+ ScheduledMaintenanceService,
1094
+ "getScheduledMaintenanceLinkInDashboard",
1095
+ )
1096
+ .mockResolvedValue(MAINTENANCE_LINK);
1097
+ });
1098
+
1099
+ describe("the time zone the start and end are read in", (): void => {
1100
+ test("reads 14:00-15:00 on Oct 1, 2030 in the submitter's America/New_York, and says so", async (): Promise<void> => {
1101
+ const turn: FakeTurn = inNewYork();
1102
+
1103
+ await submitMaintenance(turn, maintenanceForm());
1104
+
1105
+ const scheduledMaintenance: ScheduledMaintenance = eventPassedToCreate();
1106
+ expect(scheduledMaintenance.startsAt?.toISOString()).toBe(
1107
+ "2030-10-01T18:00:00.000Z",
1108
+ );
1109
+ expect(scheduledMaintenance.endsAt?.toISOString()).toBe(
1110
+ "2030-10-01T19:00:00.000Z",
1111
+ );
1112
+ expect(repliesOf(turn)).toEqual([NEW_YORK_CONFIRMATION]);
1113
+ });
1114
+
1115
+ type ZoneCase = {
1116
+ source: string;
1117
+ activity: JSONObject;
1118
+ cardTimezone?: string | undefined;
1119
+ startsAt: string;
1120
+ endsAt: string;
1121
+ label: string;
1122
+ // Only a UTC offset was known, and the confirmation says so.
1123
+ isOffsetOnly?: boolean | undefined;
1124
+ };
1125
+
1126
+ const ZONE_CASES: Array<ZoneCase> = [
1127
+ {
1128
+ source: "the clientInfo entity's zone when localTimezone is absent",
1129
+ activity: {
1130
+ entities: [
1131
+ { type: "mention", text: "<at>OneUptime</at>" },
1132
+ { type: "clientInfo", locale: "de-DE", timezone: "Europe/Berlin" },
1133
+ ],
1134
+ },
1135
+ startsAt: "2030-10-01T12:00:00.000Z",
1136
+ endsAt: "2030-10-01T13:00:00.000Z",
1137
+ label: "Europe/Berlin",
1138
+ },
1139
+ {
1140
+ source:
1141
+ "the zone the form was sent for (value.timezone) when the submit has none",
1142
+ activity: {},
1143
+ cardTimezone: "Asia/Kolkata",
1144
+ startsAt: "2030-10-01T08:30:00.000Z",
1145
+ endsAt: "2030-10-01T09:30:00.000Z",
1146
+ label: "Asia/Kolkata",
1147
+ },
1148
+ {
1149
+ // botbuilder turns localTimestamp into a Date and keeps the string.
1150
+ source: "the UTC offset of rawLocalTimestamp when no zone is known",
1151
+ activity: {
1152
+ localTimestamp: new Date("2026-09-29T12:00:00.000Z"),
1153
+ rawLocalTimestamp: "2026-09-29T17:30:00.000+05:30",
1154
+ },
1155
+ startsAt: "2030-10-01T08:30:00.000Z",
1156
+ endsAt: "2030-10-01T09:30:00.000Z",
1157
+ label: "UTC+05:30",
1158
+ isOffsetOnly: true,
1159
+ },
1160
+ {
1161
+ source: "the UTC offset of a string localTimestamp",
1162
+ activity: { localTimestamp: "2026-09-29T08:00:00.000-04:00" },
1163
+ startsAt: "2030-10-01T18:00:00.000Z",
1164
+ endsAt: "2030-10-01T19:00:00.000Z",
1165
+ label: "UTC-04:00",
1166
+ isOffsetOnly: true,
1167
+ },
1168
+ {
1169
+ // Reykjavik, say: an offset of zero is still only an offset.
1170
+ source: "a +00:00 UTC offset, named UTC but still only an offset",
1171
+ activity: { localTimestamp: "2026-09-29T12:00:00.000+00:00" },
1172
+ startsAt: "2030-10-01T14:00:00.000Z",
1173
+ endsAt: "2030-10-01T15:00:00.000Z",
1174
+ label: "UTC",
1175
+ isOffsetOnly: true,
1176
+ },
1177
+ {
1178
+ source: "UTC when Teams says nothing about the zone",
1179
+ activity: {},
1180
+ startsAt: "2030-10-01T14:00:00.000Z",
1181
+ endsAt: "2030-10-01T15:00:00.000Z",
1182
+ label: "UTC",
1183
+ },
1184
+ ];
1185
+
1186
+ test.each(ZONE_CASES)(
1187
+ "reads the times in $source",
1188
+ async (zoneCase: ZoneCase): Promise<void> => {
1189
+ const turn: FakeTurn = createFakeTurn({
1190
+ activity: submitActivity(zoneCase.activity),
1191
+ });
1192
+
1193
+ await submitMaintenance(
1194
+ turn,
1195
+ maintenanceForm(
1196
+ zoneCase.cardTimezone ? { timezone: zoneCase.cardTimezone } : {},
1197
+ ),
1198
+ );
1199
+
1200
+ const scheduledMaintenance: ScheduledMaintenance =
1201
+ eventPassedToCreate();
1202
+ expect(scheduledMaintenance.startsAt?.toISOString()).toBe(
1203
+ zoneCase.startsAt,
1204
+ );
1205
+ expect(scheduledMaintenance.endsAt?.toISOString()).toBe(
1206
+ zoneCase.endsAt,
1207
+ );
1208
+ expect(repliesOf(turn)).toEqual([
1209
+ confirmation({
1210
+ starts: `Oct 1, 2030, 14:00 (${zoneCase.label})`,
1211
+ ends: `Oct 1, 2030, 15:00 (${zoneCase.label})`,
1212
+ offsetLabel: zoneCase.isOffsetOnly ? zoneCase.label : undefined,
1213
+ link: MAINTENANCE_LINK,
1214
+ }),
1215
+ ]);
1216
+ },
1217
+ );
1218
+
1219
+ test.each([
1220
+ {
1221
+ name: "localTimezone",
1222
+ activity: { localTimezone: "America/New_York" },
1223
+ },
1224
+ {
1225
+ name: "clientInfo entity's zone",
1226
+ activity: {
1227
+ entities: [{ type: "clientInfo", timezone: "Europe/Berlin" }],
1228
+ },
1229
+ },
1230
+ {
1231
+ name: "UTC offset",
1232
+ activity: { rawLocalTimestamp: "2026-09-29T08:00:00.000-04:00" },
1233
+ },
1234
+ ])(
1235
+ "the zone the form names wins over the submitter's own $name: it is the zone the form told them to type in",
1236
+ async (row: { name: string; activity: JSONObject }): Promise<void> => {
1237
+ const turn: FakeTurn = createFakeTurn({
1238
+ activity: submitActivity(row.activity),
1239
+ });
1240
+
1241
+ await submitMaintenance(
1242
+ turn,
1243
+ maintenanceForm({ timezone: "Asia/Kolkata" }),
1244
+ );
1245
+
1246
+ expect(eventPassedToCreate().startsAt?.toISOString()).toBe(
1247
+ "2030-10-01T08:30:00.000Z",
1248
+ );
1249
+ expect(repliesOf(turn)).toEqual([KOLKATA_CONFIRMATION]);
1250
+ },
1251
+ );
1252
+
1253
+ test.each([
1254
+ {
1255
+ where: "on the submit",
1256
+ activity: {
1257
+ localTimezone: "Mars/Olympus_Mons",
1258
+ entities: [{ type: "clientInfo", timezone: "Europe/Berlin" }],
1259
+ },
1260
+ cardTimezone: undefined,
1261
+ startsAt: "2030-10-01T12:00:00.000Z",
1262
+ label: "Europe/Berlin",
1263
+ },
1264
+ {
1265
+ where: "in the form's data",
1266
+ activity: { localTimezone: "America/New_York" },
1267
+ cardTimezone: "Mars/Olympus_Mons",
1268
+ startsAt: "2030-10-01T18:00:00.000Z",
1269
+ label: "America/New_York",
1270
+ },
1271
+ ])(
1272
+ "a zone name moment-timezone does not know is skipped, not trusted, $where",
1273
+ async (row: {
1274
+ where: string;
1275
+ activity: JSONObject;
1276
+ cardTimezone: string | undefined;
1277
+ startsAt: string;
1278
+ label: string;
1279
+ }): Promise<void> => {
1280
+ const turn: FakeTurn = createFakeTurn({
1281
+ activity: submitActivity(row.activity),
1282
+ });
1283
+
1284
+ await submitMaintenance(
1285
+ turn,
1286
+ maintenanceForm(
1287
+ row.cardTimezone ? { timezone: row.cardTimezone } : {},
1288
+ ),
1289
+ );
1290
+
1291
+ expect(eventPassedToCreate().startsAt?.toISOString()).toBe(
1292
+ row.startsAt,
1293
+ );
1294
+ expect(repliesOf(turn)).toEqual([
1295
+ confirmation({
1296
+ starts: `Oct 1, 2030, 14:00 (${row.label})`,
1297
+ ends: `Oct 1, 2030, 15:00 (${row.label})`,
1298
+ link: MAINTENANCE_LINK,
1299
+ }),
1300
+ ]);
1301
+ },
1302
+ );
1303
+
1304
+ test("a winter date is read, and echoed, with the zone's winter offset, not today's", async (): Promise<void> => {
1305
+ // New York is on EDT (UTC-4) on the pinned day and on EST (UTC-5) in December.
1306
+ const turn: FakeTurn = inNewYork();
1307
+
1308
+ await submitMaintenance(
1309
+ turn,
1310
+ maintenanceForm({ startDate: "2030-12-02", endDate: "2030-12-02" }),
1311
+ );
1312
+
1313
+ const scheduledMaintenance: ScheduledMaintenance = eventPassedToCreate();
1314
+ expect(scheduledMaintenance.startsAt?.toISOString()).toBe(
1315
+ "2030-12-02T19:00:00.000Z",
1316
+ );
1317
+ expect(scheduledMaintenance.endsAt?.toISOString()).toBe(
1318
+ "2030-12-02T20:00:00.000Z",
1319
+ );
1320
+ expect(repliesOf(turn)).toEqual([
1321
+ confirmation({
1322
+ starts: "Dec 2, 2030, 14:00 (America/New_York)",
1323
+ ends: "Dec 2, 2030, 15:00 (America/New_York)",
1324
+ link: MAINTENANCE_LINK,
1325
+ }),
1326
+ ]);
1327
+ });
1328
+
1329
+ describe("round trip: the form as sent, then its submit", (): void => {
1330
+ const noChoices: MicrosoftTeamsNewScheduledMaintenanceFormChoices = {
1331
+ monitors: { choices: [], totalCount: 0 },
1332
+ monitorStatuses: { choices: [], totalCount: 0 },
1333
+ labels: { choices: [], totalCount: 0 },
1334
+ };
1335
+
1336
+ type SentForm = {
1337
+ // The card's body, serialized, for the note that names the zone.
1338
+ body: string;
1339
+ // The Action.Submit data, which Teams merges into the submit.
1340
+ submitData: JSONObject;
1341
+ };
1342
+
1343
+ // The form, built as "create maintenance" builds it for this zone.
1344
+ function sentForm(timezone: string | undefined): SentForm {
1345
+ const card: JSONObject =
1346
+ MicrosoftTeamsScheduledMaintenanceActions.buildNewScheduledMaintenanceCardForBudget(
1347
+ {
1348
+ choices: noChoices,
1349
+ budgetInBytes: MICROSOFT_TEAMS_CARD_SIZE_BUDGETS_IN_BYTES[0]!,
1350
+ timezone: timezone,
1351
+ },
1352
+ );
1353
+ const submitAction: JSONObject | undefined = (
1354
+ card["actions"] as Array<JSONObject>
1355
+ ).find((action: JSONObject) => {
1356
+ return action["type"] === "Action.Submit";
1357
+ });
1358
+
1359
+ return {
1360
+ body: JSON.stringify(card["body"]),
1361
+ submitData: submitAction!["data"] as JSONObject,
1362
+ };
1363
+ }
1364
+
1365
+ function filledIn(submitData: JSONObject): JSONObject {
1366
+ return {
1367
+ ...submitData,
1368
+ scheduledMaintenanceTitle: "Primary database upgrade",
1369
+ scheduledMaintenanceDescription: "Postgres 16 to 17.",
1370
+ startDate: "2030-10-01",
1371
+ startTime: "14:00",
1372
+ endDate: "2030-10-01",
1373
+ endTime: "15:00",
1374
+ };
1375
+ }
1376
+
1377
+ test.each([
1378
+ {
1379
+ submitter: "a client that reports no zone (Teams on iOS)",
1380
+ activity: {},
1381
+ },
1382
+ {
1383
+ submitter: "someone else in the channel, who is in New York",
1384
+ activity: { localTimezone: "America/New_York" },
1385
+ },
1386
+ ])(
1387
+ "the zone the form names is the zone its submit is read in, submitted by $submitter",
1388
+ async (row: {
1389
+ submitter: string;
1390
+ activity: JSONObject;
1391
+ }): Promise<void> => {
1392
+ // The form, as sent to a user whose "create maintenance" said Asia/Kolkata.
1393
+ const form: SentForm = sentForm("Asia/Kolkata");
1394
+
1395
+ expect(form.body).toContain(
1396
+ '"text":"Start and end times are in Asia/Kolkata."',
1397
+ );
1398
+ // It names the zone, not whose zone it is: not every reader's.
1399
+ expect(form.body).not.toContain("your time zone");
1400
+ expect(form.submitData).toEqual({
1401
+ action:
1402
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
1403
+ timezone: "Asia/Kolkata",
1404
+ });
1405
+
1406
+ const turn: FakeTurn = createFakeTurn({
1407
+ activity: submitActivity(row.activity),
1408
+ });
1409
+ await submitMaintenance(turn, filledIn(form.submitData));
1410
+
1411
+ expect(eventPassedToCreate().startsAt?.toISOString()).toBe(
1412
+ "2030-10-01T08:30:00.000Z",
1413
+ );
1414
+ expect(repliesOf(turn)[0]).toContain(
1415
+ "**Starts:** Oct 1, 2030, 14:00 (Asia/Kolkata)",
1416
+ );
1417
+ },
1418
+ );
1419
+
1420
+ test("a form that names no zone is read in the submitter's own zone", async (): Promise<void> => {
1421
+ const form: SentForm = sentForm(undefined);
1422
+
1423
+ expect(form.body).toContain(
1424
+ '"text":"Start and end times are in the time zone Microsoft Teams reports for you, or in UTC if it does not report one."',
1425
+ );
1426
+ expect(form.submitData).toEqual({
1427
+ action:
1428
+ MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
1429
+ });
1430
+
1431
+ const turn: FakeTurn = inNewYork();
1432
+ await submitMaintenance(turn, filledIn(form.submitData));
1433
+
1434
+ expect(eventPassedToCreate().startsAt?.toISOString()).toBe(
1435
+ "2030-10-01T18:00:00.000Z",
1436
+ );
1437
+ expect(repliesOf(turn)[0]).toContain(
1438
+ "**Starts:** Oct 1, 2030, 14:00 (America/New_York)",
1439
+ );
1440
+ });
1441
+ });
1442
+ });
1443
+
1444
+ describe("checks before anything is created", (): void => {
1445
+ test.each([
1446
+ { name: "no title", fields: { scheduledMaintenanceTitle: undefined } },
1447
+ {
1448
+ name: "a title of only spaces",
1449
+ fields: { scheduledMaintenanceTitle: " " },
1450
+ },
1451
+ {
1452
+ name: "no description",
1453
+ fields: { scheduledMaintenanceDescription: "" },
1454
+ },
1455
+ {
1456
+ name: "a description of only line breaks",
1457
+ fields: { scheduledMaintenanceDescription: "\n \n" },
1458
+ },
1459
+ { name: "no start date", fields: { startDate: "" } },
1460
+ { name: "no start time", fields: { startTime: "" } },
1461
+ { name: "no end date", fields: { endDate: undefined } },
1462
+ { name: "no end time", fields: { endTime: "" } },
1463
+ ])(
1464
+ "refuses $name with the missing-fields message",
1465
+ async (row: { name: string; fields: JSONObject }): Promise<void> => {
1466
+ const turn: FakeTurn = inNewYork();
1467
+
1468
+ await submitMaintenance(turn, maintenanceForm(row.fields));
1469
+
1470
+ expect(turn.events).toEqual([
1471
+ { kind: "reply", text: MAINTENANCE_MISSING_FIELDS },
1472
+ ]);
1473
+ expect(createSpy).not.toHaveBeenCalled();
1474
+ },
1475
+ );
1476
+
1477
+ test("refuses a submit that carries no user", async (): Promise<void> => {
1478
+ const turn: FakeTurn = inNewYork();
1479
+
1480
+ await submitMaintenance(turn, maintenanceForm(), {
1481
+ request: {
1482
+ isAuthorized: true,
1483
+ projectId: PROJECT_ID,
1484
+ authToken: "",
1485
+ payloadType: "invoke",
1486
+ },
1487
+ });
1488
+
1489
+ expect(turn.events).toEqual([
1490
+ {
1491
+ kind: "reply",
1492
+ text: "Unable to create scheduled maintenance: missing user or project information.",
1493
+ },
1494
+ ]);
1495
+ expect(createSpy).not.toHaveBeenCalled();
1496
+ });
1497
+
1498
+ test.each([
1499
+ {
1500
+ name: "a month that does not exist",
1501
+ fields: { startDate: "2030-13-01" },
1502
+ },
1503
+ { name: "February 30th", fields: { endDate: "2030-02-30" } },
1504
+ { name: "an hour past 23", fields: { startTime: "25:00" } },
1505
+ { name: "a US-style date", fields: { endDate: "10/01/2030" } },
1506
+ { name: "a 12-hour time", fields: { endTime: "3:00 PM" } },
1507
+ {
1508
+ name: "a date-time where a date belongs",
1509
+ fields: { startDate: "2030-10-01T14:00:00.000Z" },
1510
+ },
1511
+ ])(
1512
+ "refuses $name as unreadable and creates nothing",
1513
+ async (row: { name: string; fields: JSONObject }): Promise<void> => {
1514
+ const turn: FakeTurn = inNewYork();
1515
+
1516
+ await submitMaintenance(turn, maintenanceForm(row.fields));
1517
+
1518
+ expect(turn.events).toEqual([
1519
+ { kind: "reply", text: MAINTENANCE_UNREADABLE_TIME },
1520
+ ]);
1521
+ expect(createSpy).not.toHaveBeenCalled();
1522
+ },
1523
+ );
1524
+
1525
+ test("accepts a time with seconds", async (): Promise<void> => {
1526
+ await submitMaintenance(
1527
+ inNewYork(),
1528
+ maintenanceForm({ startTime: "14:00:00", endTime: "15:30:00" }),
1529
+ );
1530
+
1531
+ const scheduledMaintenance: ScheduledMaintenance = eventPassedToCreate();
1532
+ expect(scheduledMaintenance.startsAt?.toISOString()).toBe(
1533
+ "2030-10-01T18:00:00.000Z",
1534
+ );
1535
+ expect(scheduledMaintenance.endsAt?.toISOString()).toBe(
1536
+ "2030-10-01T19:30:00.000Z",
1537
+ );
1538
+ });
1539
+
1540
+ test("refuses a start in the past, naming it in the user's zone", async (): Promise<void> => {
1541
+ const turn: FakeTurn = inNewYork();
1542
+
1543
+ await submitMaintenance(
1544
+ turn,
1545
+ maintenanceForm({
1546
+ startDate: "2020-01-01",
1547
+ startTime: "10:00",
1548
+ endDate: "2020-01-01",
1549
+ endTime: "11:00",
1550
+ }),
1551
+ );
1552
+
1553
+ expect(turn.events).toEqual([
1554
+ {
1555
+ kind: "reply",
1556
+ text: "Unable to create scheduled maintenance: the start time (Jan 1, 2020, 10:00 (America/New_York)) is in the past.",
1557
+ },
1558
+ ]);
1559
+ expect(createSpy).not.toHaveBeenCalled();
1560
+ });
1561
+
1562
+ test("compares with now in the user's zone: 16:00 today in Kolkata is already past at 17:30 there", async (): Promise<void> => {
1563
+ // Read as UTC, 16:00 would still be four hours ahead of the pinned noon UTC.
1564
+ const turn: FakeTurn = createFakeTurn({
1565
+ activity: submitActivity({ localTimezone: "Asia/Kolkata" }),
1566
+ });
1567
+
1568
+ await submitMaintenance(
1569
+ turn,
1570
+ maintenanceForm({
1571
+ startDate: "2026-09-29",
1572
+ startTime: "16:00",
1573
+ endDate: "2026-09-29",
1574
+ endTime: "17:00",
1575
+ }),
1576
+ );
1577
+
1578
+ expect(turn.events).toEqual([
1579
+ {
1580
+ kind: "reply",
1581
+ text: "Unable to create scheduled maintenance: the start time (Sep 29, 2026, 16:00 (Asia/Kolkata)) is in the past.",
1582
+ },
1583
+ ]);
1584
+ expect(createSpy).not.toHaveBeenCalled();
1585
+ });
1586
+
1587
+ test("compares with now in the user's zone: 10:00 today in New York is still ahead at 08:00 there", async (): Promise<void> => {
1588
+ // Read as UTC, 10:00 would already be two hours behind the pinned noon UTC.
1589
+ await submitMaintenance(
1590
+ inNewYork(),
1591
+ maintenanceForm({
1592
+ startDate: "2026-09-29",
1593
+ startTime: "10:00",
1594
+ endDate: "2026-09-29",
1595
+ endTime: "11:00",
1596
+ }),
1597
+ );
1598
+
1599
+ expect(eventPassedToCreate().startsAt?.toISOString()).toBe(
1600
+ "2026-09-29T14:00:00.000Z",
1601
+ );
1602
+ });
1603
+
1604
+ describe("a start that has only just gone by", (): void => {
1605
+ /*
1606
+ * Input.Time has minute precision, so a start picked for "now" is a
1607
+ * minute or two in the past by the time the form is sent. Up to five
1608
+ * minutes late the event starts now; any later is refused. Each test
1609
+ * moves the pinned clock and submits 08:00 in New York (12:00 UTC).
1610
+ */
1611
+ const STARTS_AT_EIGHT_IN_NEW_YORK: JSONObject = {
1612
+ startDate: "2026-09-29",
1613
+ startTime: "08:00",
1614
+ endDate: "2026-09-29",
1615
+ endTime: "09:00",
1616
+ };
1617
+
1618
+ test.each([
1619
+ {
1620
+ when: "that is now",
1621
+ now: "2026-09-29T12:00:00.000Z",
1622
+ startsShown: "Sep 29, 2026, 08:00",
1623
+ },
1624
+ {
1625
+ when: "4:59 minutes in the past",
1626
+ now: "2026-09-29T12:04:59.000Z",
1627
+ startsShown: "Sep 29, 2026, 08:04",
1628
+ },
1629
+ {
1630
+ when: "exactly 5 minutes in the past",
1631
+ now: "2026-09-29T12:05:00.000Z",
1632
+ startsShown: "Sep 29, 2026, 08:05",
1633
+ },
1634
+ ])(
1635
+ "a start $when is accepted, and the event starts now",
1636
+ async (row: {
1637
+ when: string;
1638
+ now: string;
1639
+ startsShown: string;
1640
+ }): Promise<void> => {
1641
+ jest.setSystemTime(new Date(row.now));
1642
+ const turn: FakeTurn = inNewYork();
1643
+
1644
+ await submitMaintenance(
1645
+ turn,
1646
+ maintenanceForm(STARTS_AT_EIGHT_IN_NEW_YORK),
1647
+ );
1648
+
1649
+ const scheduledMaintenance: ScheduledMaintenance =
1650
+ eventPassedToCreate();
1651
+ expect(scheduledMaintenance.startsAt?.toISOString()).toBe(row.now);
1652
+ expect(scheduledMaintenance.endsAt?.toISOString()).toBe(
1653
+ "2026-09-29T13:00:00.000Z",
1654
+ );
1655
+ // The confirmation shows when it really starts.
1656
+ expect(turn.events).toEqual([
1657
+ {
1658
+ kind: "reply",
1659
+ text: confirmation({
1660
+ starts: `${row.startsShown} (America/New_York)`,
1661
+ ends: "Sep 29, 2026, 09:00 (America/New_York)",
1662
+ link: MAINTENANCE_LINK,
1663
+ }),
1664
+ },
1665
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
1666
+ ]);
1667
+ },
1668
+ );
1669
+
1670
+ test("a start 5:01 minutes in the past is refused, named as it was typed", async (): Promise<void> => {
1671
+ jest.setSystemTime(new Date("2026-09-29T12:05:01.000Z"));
1672
+ const turn: FakeTurn = inNewYork();
1673
+
1674
+ await submitMaintenance(
1675
+ turn,
1676
+ maintenanceForm(STARTS_AT_EIGHT_IN_NEW_YORK),
1677
+ );
1678
+
1679
+ expect(turn.events).toEqual([
1680
+ {
1681
+ kind: "reply",
1682
+ text: "Unable to create scheduled maintenance: the start time (Sep 29, 2026, 08:00 (America/New_York)) is in the past.",
1683
+ },
1684
+ ]);
1685
+ expect(createSpy).not.toHaveBeenCalled();
1686
+ });
1687
+
1688
+ test("the end must be after the start it was moved to, not only after the one typed", async (): Promise<void> => {
1689
+ // 08:01 is after the 08:00 typed, but not after 08:03, when it starts.
1690
+ jest.setSystemTime(new Date("2026-09-29T12:03:00.000Z"));
1691
+ const turn: FakeTurn = inNewYork();
1692
+
1693
+ await submitMaintenance(
1694
+ turn,
1695
+ maintenanceForm({ ...STARTS_AT_EIGHT_IN_NEW_YORK, endTime: "08:01" }),
1696
+ );
1697
+
1698
+ expect(turn.events).toEqual([
1699
+ {
1700
+ kind: "reply",
1701
+ text: "Unable to create scheduled maintenance: the end time (Sep 29, 2026, 08:01 (America/New_York)) must be after the start time (Sep 29, 2026, 08:03 (America/New_York)).",
1702
+ },
1703
+ ]);
1704
+ expect(createSpy).not.toHaveBeenCalled();
1705
+ });
1706
+ });
1707
+
1708
+ test.each([
1709
+ {
1710
+ name: "an end an hour before the start",
1711
+ endDate: "2030-10-01",
1712
+ endTime: "13:00",
1713
+ endsAt: "Oct 1, 2030, 13:00",
1714
+ },
1715
+ {
1716
+ name: "an end equal to the start",
1717
+ endDate: "2030-10-01",
1718
+ endTime: "14:00",
1719
+ endsAt: "Oct 1, 2030, 14:00",
1720
+ },
1721
+ {
1722
+ name: "an end the day before, at a later hour",
1723
+ endDate: "2030-09-30",
1724
+ endTime: "16:00",
1725
+ endsAt: "Sep 30, 2030, 16:00",
1726
+ },
1727
+ ])(
1728
+ "refuses $name, naming both times",
1729
+ async (row: {
1730
+ name: string;
1731
+ endDate: string;
1732
+ endTime: string;
1733
+ endsAt: string;
1734
+ }): Promise<void> => {
1735
+ const turn: FakeTurn = inNewYork();
1736
+
1737
+ await submitMaintenance(
1738
+ turn,
1739
+ maintenanceForm({ endDate: row.endDate, endTime: row.endTime }),
1740
+ );
1741
+
1742
+ expect(turn.events).toEqual([
1743
+ {
1744
+ kind: "reply",
1745
+ text: `Unable to create scheduled maintenance: the end time (${row.endsAt} (America/New_York)) must be after the start time (Oct 1, 2030, 14:00 (America/New_York)).`,
1746
+ },
1747
+ ]);
1748
+ expect(createSpy).not.toHaveBeenCalled();
1749
+ },
1750
+ );
1751
+
1752
+ test("refuses a title of 101 characters", async (): Promise<void> => {
1753
+ const turn: FakeTurn = inNewYork();
1754
+
1755
+ await submitMaintenance(
1756
+ turn,
1757
+ maintenanceForm({ scheduledMaintenanceTitle: "M".repeat(101) }),
1758
+ );
1759
+
1760
+ expect(turn.events).toEqual([
1761
+ {
1762
+ kind: "reply",
1763
+ text: "Unable to create scheduled maintenance: the title can be at most 100 characters.",
1764
+ },
1765
+ ]);
1766
+ expect(createSpy).not.toHaveBeenCalled();
1767
+ });
1768
+
1769
+ test("accepts a title of exactly 100 characters, counted after trimming", async (): Promise<void> => {
1770
+ const turn: FakeTurn = inNewYork();
1771
+
1772
+ await submitMaintenance(
1773
+ turn,
1774
+ maintenanceForm({
1775
+ scheduledMaintenanceTitle: ` ${"M".repeat(100)} `,
1776
+ }),
1777
+ );
1778
+
1779
+ expect(eventPassedToCreate().title).toBe("M".repeat(100));
1780
+ expect(repliesOf(turn)).toEqual([NEW_YORK_CONFIRMATION]);
1781
+ });
1782
+
1783
+ test("asks whether the member may create a scheduled maintenance event, with the member's own props", async (): Promise<void> => {
1784
+ await submitMaintenance(inNewYork(), maintenanceForm());
1785
+
1786
+ expect(assertCanCreateSpy).toHaveBeenCalledTimes(1);
1787
+ const permissionCheck: Parameters<
1788
+ typeof WorkspaceActionAuthorization.assertCanCreate
1789
+ >[0] = assertCanCreateSpy.mock.calls[0]![0];
1790
+
1791
+ /*
1792
+ * The model is compared by name: a model class inside a failed toEqual
1793
+ * breaks jest's worker (its results go to the parent as JSON, and
1794
+ * JSON.stringify calls DatabaseBaseModel's static toJSON), so the file
1795
+ * would report "modelType is not a constructor" and no test results.
1796
+ */
1797
+ expect({
1798
+ props: permissionCheck.props,
1799
+ modelType: permissionCheck.modelType.name,
1800
+ action: permissionCheck.action,
1801
+ resources: permissionCheck.resources,
1802
+ }).toEqual({
1803
+ props: MEMBER_PROPS,
1804
+ modelType: "ScheduledMaintenance",
1805
+ action: "create a scheduled maintenance event",
1806
+ resources: undefined,
1807
+ });
1808
+ expect(permissionCheck.modelType === ScheduledMaintenance).toBe(true);
1809
+ expect(assertCanCreateSpy.mock.invocationCallOrder[0]!).toBeLessThan(
1810
+ createSpy.mock.invocationCallOrder[0]!,
1811
+ );
1812
+ });
1813
+
1814
+ test("a member without permission is told so, once, and nothing is created", async (): Promise<void> => {
1815
+ const refusal: string =
1816
+ "You do not have permission to create a scheduled maintenance event. You need the Create Scheduled Maintenance permission.";
1817
+ assertCanCreateSpy.mockRejectedValue(new NotAuthorizedException(refusal));
1818
+ const turn: FakeTurn = inNewYork();
1819
+
1820
+ await expect(
1821
+ submitMaintenance(turn, maintenanceForm()),
1822
+ ).resolves.toBeUndefined();
1823
+
1824
+ expect(turn.events).toEqual([{ kind: "reply", text: refusal }]);
1825
+ expect(validateReferencesSpy).not.toHaveBeenCalled();
1826
+ expect(createSpy).not.toHaveBeenCalled();
1827
+ });
1828
+
1829
+ test("a permission check that fails unexpectedly gets one generic reply, and nothing is created", async (): Promise<void> => {
1830
+ assertCanCreateSpy.mockRejectedValue(
1831
+ new Error("Connection terminated unexpectedly"),
1832
+ );
1833
+ const turn: FakeTurn = inNewYork();
1834
+
1835
+ await expect(
1836
+ submitMaintenance(turn, maintenanceForm()),
1837
+ ).resolves.toBeUndefined();
1838
+
1839
+ expect(turn.events).toEqual([
1840
+ {
1841
+ kind: "reply",
1842
+ text: "An error occurred while processing the action",
1843
+ },
1844
+ ]);
1845
+ expect(createSpy).not.toHaveBeenCalled();
1846
+ });
1847
+ });
1848
+
1849
+ describe("who may submit it", (): void => {
1850
+ // The real permission check, on the permissions the member really holds.
1851
+ beforeEach((): void => {
1852
+ assertCanCreateSpy.mockRestore();
1853
+ });
1854
+
1855
+ test("a read-only member (Viewer) is told why, once, and nothing is created", async (): Promise<void> => {
1856
+ const turn: FakeTurn = inNewYork();
1857
+
1858
+ await expect(
1859
+ submitMaintenance(turn, maintenanceForm(), {
1860
+ databaseProps: memberWith([Permission.Viewer]),
1861
+ }),
1862
+ ).resolves.toBeUndefined();
1863
+
1864
+ expect(turn.events).toEqual([
1865
+ {
1866
+ kind: "reply",
1867
+ text: expect.stringMatching(
1868
+ /^You do not have permission to create a scheduled maintenance event\./,
1869
+ ),
1870
+ },
1871
+ ]);
1872
+ expect(validateReferencesSpy).not.toHaveBeenCalled();
1873
+ expect(createSpy).not.toHaveBeenCalled();
1874
+ });
1875
+
1876
+ test("a member whose only permission is Create Scheduled Maintenance creates one", async (): Promise<void> => {
1877
+ const turn: FakeTurn = inNewYork();
1878
+
1879
+ await submitMaintenance(turn, maintenanceForm(), {
1880
+ databaseProps: memberWith([
1881
+ Permission.CreateProjectScheduledMaintenance,
1882
+ ]),
1883
+ });
1884
+
1885
+ expect(eventPassedToCreate().title).toBe("Primary database upgrade");
1886
+ expect(repliesOf(turn)).toEqual([NEW_YORK_CONFIRMATION]);
1887
+ });
1888
+
1889
+ test("a linked account that is not a member of the project is refused before the form is read: told why, once, and nothing is created", async (): Promise<void> => {
1890
+ const handlerSpy: SpyInstance<
1891
+ typeof MicrosoftTeamsScheduledMaintenanceActions.handleBotScheduledMaintenanceAction
1892
+ > = jest.spyOn(
1893
+ MicrosoftTeamsScheduledMaintenanceActions,
1894
+ "handleBotScheduledMaintenanceAction",
1895
+ );
1896
+
1897
+ const submitted: NonMemberSubmit =
1898
+ await submitAsLinkedNonMember(maintenanceForm());
1899
+
1900
+ expectRefusedAsNonMember(submitted);
1901
+ expect(handlerSpy).not.toHaveBeenCalled();
1902
+ expect(validateReferencesSpy).not.toHaveBeenCalled();
1903
+ expect(createSpy).not.toHaveBeenCalled();
1904
+ expect(errorLogSpy).not.toHaveBeenCalled();
1905
+ });
1906
+ });
1907
+
1908
+ describe("creating the event", (): void => {
1909
+ test("creates the event as root in the project, as the user, from the trimmed form, and hands the monitor status to the service", async (): Promise<void> => {
1910
+ await submitMaintenance(
1911
+ inNewYork(),
1912
+ maintenanceForm({
1913
+ scheduledMaintenanceTitle: " Primary database upgrade ",
1914
+ scheduledMaintenanceDescription: "\nPostgres 16 to 17.\n",
1915
+ scheduledMaintenanceMonitors: `${MONITOR_ID},${SECOND_MONITOR_ID}`,
1916
+ monitorStatus: MONITOR_STATUS_ID,
1917
+ labels: LABEL_ID,
1918
+ }),
1919
+ );
1920
+
1921
+ expect(createSpy.mock.calls[0]![0].props).toEqual({ isRoot: true });
1922
+
1923
+ const scheduledMaintenance: ScheduledMaintenance = eventPassedToCreate();
1924
+ expect(scheduledMaintenance.title).toBe("Primary database upgrade");
1925
+ expect(scheduledMaintenance.description).toBe("Postgres 16 to 17.");
1926
+ expect(scheduledMaintenance.projectId?.toString()).toBe(
1927
+ PROJECT_ID.toString(),
1928
+ );
1929
+ expect(scheduledMaintenance.createdByUserId?.toString()).toBe(
1930
+ USER_ID.toString(),
1931
+ );
1932
+ expect(relationIds(scheduledMaintenance.monitors)).toEqual([
1933
+ MONITOR_ID,
1934
+ SECOND_MONITOR_ID,
1935
+ ]);
1936
+ expect(relationIds(scheduledMaintenance.labels)).toEqual([LABEL_ID]);
1937
+ expect(scheduledMaintenance.changeMonitorStatusToId?.toString()).toBe(
1938
+ MONITOR_STATUS_ID,
1939
+ );
1940
+
1941
+ // Every submitted id is checked against the project before the create.
1942
+ expect(validateReferencesSpy).toHaveBeenCalledTimes(1);
1943
+ expect(validateReferencesSpy.mock.invocationCallOrder[0]!).toBeLessThan(
1944
+ createSpy.mock.invocationCallOrder[0]!,
1945
+ );
1946
+ expect(validateReferencesSpy.mock.calls[0]![0]).toEqual({
1947
+ projectId: PROJECT_ID,
1948
+ subject: "scheduled maintenance event",
1949
+ monitorIds: [new ObjectID(MONITOR_ID), new ObjectID(SECOND_MONITOR_ID)],
1950
+ labelIds: [new ObjectID(LABEL_ID)],
1951
+ monitorStatusId: new ObjectID(MONITOR_STATUS_ID),
1952
+ });
1953
+ expectNoDirectMonitorWrites();
1954
+ });
1955
+
1956
+ test("drops a monitor status submitted without monitors", async (): Promise<void> => {
1957
+ await submitMaintenance(
1958
+ inNewYork(),
1959
+ maintenanceForm({ monitorStatus: MONITOR_STATUS_ID }),
1960
+ );
1961
+
1962
+ const scheduledMaintenance: ScheduledMaintenance = eventPassedToCreate();
1963
+ expect(scheduledMaintenance.changeMonitorStatusToId).toBeUndefined();
1964
+ expect(scheduledMaintenance.monitors).toBeUndefined();
1965
+ expect(
1966
+ validateReferencesSpy.mock.calls[0]![0].monitorStatusId,
1967
+ ).toBeUndefined();
1968
+ expectNoDirectMonitorWrites();
1969
+ });
1970
+
1971
+ test("asks for no status change when monitors are chosen without a status", async (): Promise<void> => {
1972
+ await submitMaintenance(
1973
+ inNewYork(),
1974
+ maintenanceForm({ scheduledMaintenanceMonitors: MONITOR_ID }),
1975
+ );
1976
+
1977
+ const scheduledMaintenance: ScheduledMaintenance = eventPassedToCreate();
1978
+ expect(relationIds(scheduledMaintenance.monitors)).toEqual([MONITOR_ID]);
1979
+ expect(scheduledMaintenance.changeMonitorStatusToId).toBeUndefined();
1980
+ expectNoDirectMonitorWrites();
1981
+ });
1982
+
1983
+ test.each([
1984
+ {
1985
+ name: "ScheduledMaintenanceService.create refusing with a BadDataException, with OneUptime's reason",
1986
+ where: "create",
1987
+ error: new BadDataException(
1988
+ "Scheduled maintenance state not found in project.",
1989
+ ),
1990
+ reply:
1991
+ "❌ Could not create the scheduled maintenance event: Scheduled maintenance state not found in project.",
1992
+ logged:
1993
+ "BadDataException: Scheduled maintenance state not found in project.",
1994
+ },
1995
+ {
1996
+ name: "ScheduledMaintenanceService.create refusing with a NotAuthorizedException, with its message",
1997
+ where: "create",
1998
+ error: new NotAuthorizedException(
1999
+ "You do not have permission to create a scheduled maintenance event.",
2000
+ ),
2001
+ reply:
2002
+ "❌ Could not create the scheduled maintenance event: You do not have permission to create a scheduled maintenance event.",
2003
+ logged:
2004
+ "NotAuthorizedException: You do not have permission to create a scheduled maintenance event.",
2005
+ },
2006
+ {
2007
+ name: "the reference check refusing another project's label, with the fixed line",
2008
+ where: "validator",
2009
+ error: new ProjectScopedReferenceException(
2010
+ 'This scheduled maintenance event references records that belong to a different project: Label "acme-payroll". Please pick values from this project and try again.',
2011
+ ),
2012
+ reply: MAINTENANCE_REFERENCE_UNAVAILABLE,
2013
+ logged:
2014
+ 'ProjectScopedReferenceException: This scheduled maintenance event references records that belong to a different project: Label "acme-payroll". Please pick values from this project and try again.',
2015
+ },
2016
+ {
2017
+ // Deleted after the form was sent; the service checks it on create.
2018
+ name: "ScheduledMaintenanceService.create refusing a monitor status the project no longer has, with the fixed line",
2019
+ where: "create",
2020
+ error: new ProjectScopedReferenceException(
2021
+ `This scheduled maintenance event references records that do not exist: Monitor Status "${MONITOR_STATUS_ID}". Please pick values that exist in this project and try again.`,
2022
+ ),
2023
+ reply: MAINTENANCE_REFERENCE_UNAVAILABLE,
2024
+ logged: `ProjectScopedReferenceException: This scheduled maintenance event references records that do not exist: Monitor Status "${MONITOR_STATUS_ID}". Please pick values that exist in this project and try again.`,
2025
+ },
2026
+ {
2027
+ name: "a BadDataException without a message, with the generic line",
2028
+ where: "create",
2029
+ error: new BadDataException(""),
2030
+ reply: MAINTENANCE_UNEXPECTED_FAILURE,
2031
+ logged: "BadDataException: ",
2032
+ },
2033
+ ])(
2034
+ "answers $name, once, logs it, and leaves the form",
2035
+ async (row: {
2036
+ name: string;
2037
+ where: string;
2038
+ error: Error;
2039
+ reply: string;
2040
+ logged: string;
2041
+ }): Promise<void> => {
2042
+ if (row.where === "validator") {
2043
+ validateReferencesSpy.mockRejectedValue(row.error);
2044
+ } else {
2045
+ createSpy.mockRejectedValue(row.error);
2046
+ }
2047
+ const turn: FakeTurn = inNewYork();
2048
+
2049
+ await expect(
2050
+ submitMaintenance(turn, maintenanceForm({ labels: LABEL_ID })),
2051
+ ).resolves.toBeUndefined();
2052
+
2053
+ expect(turn.events).toEqual([{ kind: "reply", text: row.reply }]);
2054
+ // A refused reference check stops the create; nothing to link to.
2055
+ expect(createSpy).toHaveBeenCalledTimes(row.where === "create" ? 1 : 0);
2056
+ expect(linkSpy).not.toHaveBeenCalled();
2057
+ expectOnlyCreateFailureLogged({
2058
+ summary: `Could not create a scheduled maintenance event from Microsoft Teams: ${row.logged}`,
2059
+ error: row.error,
2060
+ errorObjectLogged: true,
2061
+ });
2062
+ },
2063
+ );
2064
+
2065
+ test("a reference the project does not have is answered without naming the record; the log keeps which one it was", async (): Promise<void> => {
2066
+ const validatorMessage: string =
2067
+ 'This scheduled maintenance event references records that belong to a different project: Monitor "Acme Corp payroll API". Please pick values from this project and try again.';
2068
+ validateReferencesSpy.mockRejectedValue(
2069
+ new ProjectScopedReferenceException(validatorMessage),
2070
+ );
2071
+ const turn: FakeTurn = inNewYork();
2072
+
2073
+ await submitMaintenance(
2074
+ turn,
2075
+ maintenanceForm({ scheduledMaintenanceMonitors: MONITOR_ID }),
2076
+ );
2077
+
2078
+ expect(repliesOf(turn)).toEqual([MAINTENANCE_REFERENCE_UNAVAILABLE]);
2079
+ for (const named of ["Acme", "payroll", MONITOR_ID]) {
2080
+ expect(repliesOf(turn)[0]).not.toContain(named);
2081
+ }
2082
+ expect(createSpy).not.toHaveBeenCalled();
2083
+ expect(errorLogSpy.mock.calls[0]![0]).toBe(
2084
+ `Could not create a scheduled maintenance event from Microsoft Teams: ProjectScopedReferenceException: ${validatorMessage}`,
2085
+ );
2086
+ });
2087
+
2088
+ test("answers an unexpected create failure once, with the generic line and where to create it instead; the details go to the log only", async (): Promise<void> => {
2089
+ const failure: Error = new Error("Connection terminated unexpectedly");
2090
+ createSpy.mockRejectedValue(failure);
2091
+ const turn: FakeTurn = inNewYork();
2092
+
2093
+ await expect(
2094
+ submitMaintenance(turn, maintenanceForm()),
2095
+ ).resolves.toBeUndefined();
2096
+
2097
+ expect(turn.events).toEqual([
2098
+ { kind: "reply", text: MAINTENANCE_UNEXPECTED_FAILURE },
2099
+ ]);
2100
+ expectOnlyCreateFailureLogged({
2101
+ summary:
2102
+ "Could not create a scheduled maintenance event from Microsoft Teams: Error: Connection terminated unexpectedly",
2103
+ error: failure,
2104
+ errorObjectLogged: true,
2105
+ });
2106
+ });
2107
+
2108
+ test("the generic line ends with a full stop when no dashboard link can be built", async (): Promise<void> => {
2109
+ dashboardUrlSpy.mockRejectedValue(new Error("GlobalConfig is not ready"));
2110
+ createSpy.mockRejectedValue(
2111
+ new Error("Connection terminated unexpectedly"),
2112
+ );
2113
+ const turn: FakeTurn = inNewYork();
2114
+
2115
+ await expect(
2116
+ submitMaintenance(turn, maintenanceForm()),
2117
+ ).resolves.toBeUndefined();
2118
+
2119
+ expect(turn.events).toEqual([
2120
+ { kind: "reply", text: MAINTENANCE_UNEXPECTED_FAILURE_WITHOUT_LINK },
2121
+ ]);
2122
+ });
2123
+
2124
+ test("a failure that carries an HTTP status is logged in one line: its status and code say it all", async (): Promise<void> => {
2125
+ const failure: Error = teamsRefusal(503, "ServiceUnavailable");
2126
+ createSpy.mockRejectedValue(failure);
2127
+ const turn: FakeTurn = inNewYork();
2128
+
2129
+ await expect(
2130
+ submitMaintenance(turn, maintenanceForm()),
2131
+ ).resolves.toBeUndefined();
2132
+
2133
+ expect(turn.events).toEqual([
2134
+ { kind: "reply", text: MAINTENANCE_UNEXPECTED_FAILURE },
2135
+ ]);
2136
+ expectOnlyCreateFailureLogged({
2137
+ summary:
2138
+ "Could not create a scheduled maintenance event from Microsoft Teams: RestError 503 ServiceUnavailable: Teams answered 503 ServiceUnavailable.",
2139
+ error: failure,
2140
+ errorObjectLogged: false,
2141
+ });
2142
+ });
2143
+
2144
+ test("a failure reply Teams refuses does not escape, nor turn into a second, different answer", async (): Promise<void> => {
2145
+ createSpy.mockRejectedValue(
2146
+ new BadDataException("Scheduled maintenance state not found."),
2147
+ );
2148
+ const turn: FakeTurn = createFakeTurn({
2149
+ activity: submitActivity({ localTimezone: "America/New_York" }),
2150
+ replyError: teamsRefusal(502, "BadGateway"),
2151
+ });
2152
+
2153
+ await expect(
2154
+ submitMaintenance(turn, maintenanceForm()),
2155
+ ).resolves.toBeUndefined();
2156
+
2157
+ expect(turn.events).toEqual([
2158
+ {
2159
+ kind: "reply",
2160
+ text: "❌ Could not create the scheduled maintenance event: Scheduled maintenance state not found.",
2161
+ },
2162
+ ]);
2163
+ });
2164
+
2165
+ test("confirms first (Starts and Ends in the user's zone, then the link), and only then removes the form", async (): Promise<void> => {
2166
+ const turn: FakeTurn = inNewYork();
2167
+
2168
+ await submitMaintenance(turn, maintenanceForm());
2169
+
2170
+ expect(turn.events).toEqual([
2171
+ { kind: "reply", text: NEW_YORK_CONFIRMATION },
2172
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
2173
+ ]);
2174
+ expect(linkSpy).toHaveBeenCalledTimes(1);
2175
+ expect(linkSpy.mock.calls[0]![0].toString()).toBe(PROJECT_ID.toString());
2176
+ expect(linkSpy.mock.calls[0]![1].toString()).toBe(CREATED_ID.toString());
2177
+ });
2178
+
2179
+ test("a form Teams will not remove leaves just the confirmation: no failure text, nothing thrown", async (): Promise<void> => {
2180
+ const turn: FakeTurn = createFakeTurn({
2181
+ activity: submitActivity({ localTimezone: "America/New_York" }),
2182
+ deleteError: teamsRefusal(404, "MessageNotFound"),
2183
+ });
2184
+
2185
+ await expect(
2186
+ submitMaintenance(turn, maintenanceForm()),
2187
+ ).resolves.toBeUndefined();
2188
+
2189
+ expect(turn.events).toEqual([
2190
+ { kind: "reply", text: NEW_YORK_CONFIRMATION },
2191
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
2192
+ ]);
2193
+ });
2194
+
2195
+ test("a link that cannot be built still confirms, with the times", async (): Promise<void> => {
2196
+ linkSpy.mockRejectedValue(new Error("DashboardUrl is not configured"));
2197
+ const turn: FakeTurn = inNewYork();
2198
+
2199
+ await submitMaintenance(turn, maintenanceForm());
2200
+
2201
+ expect(turn.events).toEqual([
2202
+ {
2203
+ kind: "reply",
2204
+ text: confirmation({
2205
+ starts: "Oct 1, 2030, 14:00 (America/New_York)",
2206
+ ends: "Oct 1, 2030, 15:00 (America/New_York)",
2207
+ }),
2208
+ },
2209
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
2210
+ ]);
2211
+ });
2212
+
2213
+ test("an event read at a bare offset, whose link cannot be built, still says so", async (): Promise<void> => {
2214
+ linkSpy.mockRejectedValue(new Error("DashboardUrl is not configured"));
2215
+ const turn: FakeTurn = createFakeTurn({
2216
+ activity: submitActivity({
2217
+ rawLocalTimestamp: "2026-09-29T17:30:00.000+05:30",
2218
+ }),
2219
+ });
2220
+
2221
+ await submitMaintenance(turn, maintenanceForm());
2222
+
2223
+ expect(repliesOf(turn)).toEqual([
2224
+ confirmation({
2225
+ starts: "Oct 1, 2030, 14:00 (UTC+05:30)",
2226
+ ends: "Oct 1, 2030, 15:00 (UTC+05:30)",
2227
+ offsetLabel: "UTC+05:30",
2228
+ }),
2229
+ ]);
2230
+ });
2231
+
2232
+ test("an event created without an id is confirmed with its times, without a link", async (): Promise<void> => {
2233
+ createSpy.mockResolvedValue(new ScheduledMaintenance());
2234
+ const turn: FakeTurn = inNewYork();
2235
+
2236
+ await submitMaintenance(turn, maintenanceForm());
2237
+
2238
+ expect(turn.events).toEqual([
2239
+ {
2240
+ kind: "reply",
2241
+ text: confirmation({
2242
+ starts: "Oct 1, 2030, 14:00 (America/New_York)",
2243
+ ends: "Oct 1, 2030, 15:00 (America/New_York)",
2244
+ }),
2245
+ },
2246
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
2247
+ ]);
2248
+ expect(linkSpy).not.toHaveBeenCalled();
2249
+ });
2250
+
2251
+ test("an event handed back without its project is linked in the linked project", async (): Promise<void> => {
2252
+ const created: ScheduledMaintenance = new ScheduledMaintenance();
2253
+ created.id = CREATED_ID;
2254
+ createSpy.mockResolvedValue(created);
2255
+ const turn: FakeTurn = inNewYork();
2256
+
2257
+ await submitMaintenance(turn, maintenanceForm());
2258
+
2259
+ expect(repliesOf(turn)).toEqual([NEW_YORK_CONFIRMATION]);
2260
+ expect(linkSpy).toHaveBeenCalledTimes(1);
2261
+ expect(linkSpy.mock.calls[0]![0].toString()).toBe(PROJECT_ID.toString());
2262
+ expect(linkSpy.mock.calls[0]![1].toString()).toBe(CREATED_ID.toString());
2263
+ });
2264
+
2265
+ test("a submit without a replyToId is confirmed, and nothing is removed", async (): Promise<void> => {
2266
+ const turn: FakeTurn = inNewYork({ replyToId: undefined });
2267
+
2268
+ await submitMaintenance(turn, maintenanceForm());
2269
+
2270
+ expect(turn.events).toEqual([
2271
+ { kind: "reply", text: NEW_YORK_CONFIRMATION },
2272
+ ]);
2273
+ });
2274
+
2275
+ test("a confirmation Teams refuses is not followed by a failure reply, and the form still goes", async (): Promise<void> => {
2276
+ const turn: FakeTurn = createFakeTurn({
2277
+ activity: submitActivity({ localTimezone: "America/New_York" }),
2278
+ replyError: teamsRefusal(502, "BadGateway"),
2279
+ });
2280
+
2281
+ await expect(
2282
+ submitMaintenance(turn, maintenanceForm()),
2283
+ ).resolves.toBeUndefined();
2284
+
2285
+ // The event exists; a form left in place invites a duplicate.
2286
+ expect(createSpy).toHaveBeenCalledTimes(1);
2287
+ expect(turn.events).toEqual([
2288
+ { kind: "reply", text: NEW_YORK_CONFIRMATION },
2289
+ { kind: "delete", activityId: FORM_ACTIVITY_ID },
2290
+ ]);
2291
+ });
2292
+ });
2293
+ });