@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.
- package/Models/DatabaseModels/AutoRemediationSuggestion.ts +60 -0
- package/Models/DatabaseModels/CephCluster.ts +213 -0
- package/Models/DatabaseModels/DatabaseServer.ts +213 -0
- package/Models/DatabaseModels/DockerHost.ts +213 -0
- package/Models/DatabaseModels/DockerSwarmCluster.ts +213 -0
- package/Models/DatabaseModels/GlobalConfig.ts +1 -1
- package/Models/DatabaseModels/Host.ts +213 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/PodmanHost.ts +213 -0
- package/Models/DatabaseModels/ProxmoxCluster.ts +213 -0
- package/Models/DatabaseModels/ResourceAiAgent.ts +419 -0
- package/Models/DatabaseModels/RunnerJob.ts +144 -0
- package/Models/DatabaseModels/VMwareVCenter.ts +213 -0
- package/Server/API/AutoRemediationAPI.ts +392 -1
- package/Server/API/ResourceAiAccessAPI.ts +1669 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.ts +423 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Infrastructure/Semaphore.ts +22 -0
- package/Server/Middleware/TelemetryIngest.ts +15 -5
- package/Server/Services/AnalyticsDatabaseService.ts +45 -1
- package/Server/Services/AutoRemediationRuleEngineService.ts +940 -112
- package/Server/Services/CephClusterService.ts +109 -1
- package/Server/Services/DatabaseServerService.ts +116 -1
- package/Server/Services/DockerHostService.ts +109 -1
- package/Server/Services/DockerSwarmClusterService.ts +113 -1
- package/Server/Services/HostService.ts +126 -4
- package/Server/Services/MetricRecordingRuleService.ts +47 -0
- package/Server/Services/PodmanHostService.ts +109 -1
- package/Server/Services/ProxmoxClusterService.ts +110 -1
- package/Server/Services/ResourceAiAccessService.ts +1439 -0
- package/Server/Services/ResourceAiAgentJobService.ts +202 -0
- package/Server/Services/ResourceAiAgentService.ts +2432 -0
- package/Server/Services/RunnerJobService.ts +611 -4
- package/Server/Services/TelemetryUsageBillingService.ts +28 -0
- package/Server/Services/TraceRecordingRuleService.ts +33 -0
- package/Server/Services/VMwareVCenterService.ts +110 -1
- package/Server/Utils/AI/Remediation/RemediationCommandTools.ts +1434 -32
- package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +935 -21
- package/Server/Utils/AI/Remediation/RemediationPlanRunner.ts +25 -1
- package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +577 -0
- package/Server/Utils/AI/ResourceAccess/ResourceAccessContext.ts +271 -0
- package/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.ts +45 -0
- package/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.ts +964 -0
- package/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.ts +333 -0
- package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +873 -0
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +72 -2
- package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +72 -5
- package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +72 -5
- package/Server/Utils/AutoRemediation/CommandPlanExecutor.ts +396 -13
- package/Server/Utils/AutoRemediation/RemediationVerifier.ts +35 -2
- package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +9 -1
- package/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.ts +920 -0
- package/Server/Utils/SessionReplay/SessionReplayUsage.ts +84 -6
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +35 -15
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.ts +33 -15
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +21 -1
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +298 -224
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +35 -15
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.ts +389 -175
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +366 -143
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.ts +163 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.ts +396 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.ts +418 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.ts +152 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.ts +251 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.ts +214 -0
- package/Tests/App/Dashboard/ClusterAccessNotice.test.tsx +93 -0
- package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +18 -6
- package/Tests/App/Dashboard/InvestigationInfrastructureTools.test.tsx +506 -0
- package/Tests/App/Dashboard/RemediationSuggestionCardDescription.test.tsx +209 -0
- package/Tests/App/Dashboard/RemediationSuggestionCardResource.test.tsx +317 -0
- package/Tests/App/Dashboard/ResourceAiAccessSettingsUtil.test.ts +921 -0
- package/Tests/App/Dashboard/ResourceAiAgentInstall.test.ts +860 -0
- package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +1721 -0
- package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +1002 -0
- package/Tests/App/Dashboard/ResourceAiInsightsPage.test.tsx +931 -0
- package/Tests/App/Dashboard/ResourceAiNavigation.test.tsx +575 -0
- package/Tests/App/Dashboard/RunbookStepTypeMaps.test.ts +38 -7
- package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +33 -1
- package/Tests/Models/DatabaseModels/ResourceAiAccessColumns.test.ts +738 -0
- package/Tests/Models/DatabaseModels/ResourceAiAgentModel.test.ts +842 -0
- package/Tests/Server/API/AutoRemediationApproveResourceRound.test.ts +835 -0
- package/Tests/Server/API/ResourceAiAccessAPI.test.ts +2730 -0
- package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +60 -0
- package/Tests/Server/Infrastructure/Postgres/AddResourceAiAgentsMigration.test.ts +659 -0
- package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +31 -0
- package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +63 -5
- package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerPinnedKey.test.ts +103 -5
- package/Tests/Server/Middleware/TelemetryIngestKubernetesAgentRunnerRateLimit.test.ts +19 -1
- package/Tests/Server/Services/AutoRemediationResourceRuleEngine.test.ts +1279 -0
- package/Tests/Server/Services/DatabaseServerService.test.ts +55 -0
- package/Tests/Server/Services/GroupTelemetryUsageExcludeNames.test.ts +299 -0
- package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +44 -0
- package/Tests/Server/Services/MonitorProbeServiceIntervalScheduling.test.ts +45 -6
- package/Tests/Server/Services/RecordingRuleReservedMetricName.test.ts +171 -0
- package/Tests/Server/Services/ResourceAiAccessChangeAuthorization.test.ts +256 -0
- package/Tests/Server/Services/ResourceAiAccessService.test.ts +1373 -0
- package/Tests/Server/Services/ResourceAiAgentJobService.test.ts +375 -0
- package/Tests/Server/Services/ResourceAiAgentServiceHelpers.test.ts +1114 -0
- package/Tests/Server/Services/ResourceAiAgentServiceLifecycle.test.ts +1050 -0
- package/Tests/Server/Services/ResourceAiAgentServiceRegister.test.ts +2251 -0
- package/Tests/Server/Services/ResourceAiSettingsCreate.test.ts +373 -0
- package/Tests/Server/Services/ResourceAiSettingsPermission.test.ts +1042 -0
- package/Tests/Server/Services/ResourceServiceDeleteCleansUpAi.test.ts +319 -0
- package/Tests/Server/Services/RunnerJobEnqueueKubectl.test.ts +105 -0
- package/Tests/Server/Services/RunnerJobEnqueueResourceCommand.test.ts +986 -0
- package/Tests/Server/Services/RunnerJobResourceCommandLane.test.ts +211 -0
- package/Tests/Server/Services/RunnerJobResourceCommandTimeoutAndRedaction.test.ts +352 -0
- package/Tests/Server/Services/TelemetryUsageBillingSloExclusion.test.ts +218 -0
- package/Tests/Server/TestingUtils/Services/FakeRunnerJobCount.ts +145 -0
- package/Tests/Server/Utils/AI/InvestigationInfrastructureAccessWiring.test.ts +453 -0
- package/Tests/Server/Utils/AI/InvestigationInfrastructureReport.test.ts +721 -0
- package/Tests/Server/Utils/AI/RemediationCommandTools.test.ts +94 -1
- package/Tests/Server/Utils/AI/RemediationCommandToolsResource.test.ts +1530 -0
- package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +1249 -0
- package/Tests/Server/Utils/AI/RemediationPlanRunner.test.ts +117 -0
- package/Tests/Server/Utils/AI/RemediationResourceCopyParity.test.ts +463 -0
- package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +722 -0
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceAccessContext.test.ts +266 -0
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.test.ts +1220 -0
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.test.ts +569 -0
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +830 -0
- package/Tests/Server/Utils/AutoRemediation/CommandPlanExecutorResource.test.ts +717 -0
- package/Tests/Server/Utils/AutoRemediation/RemediationVerifierResourceFollowUp.test.ts +338 -0
- package/Tests/Server/Utils/Monitor/Criteria/SessionReplayBudgetTemplateCriteria.test.ts +422 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.test.ts +1647 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +143 -1
- package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +33 -3
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsAccountNotLinked.test.ts +1093 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.test.ts +1049 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.test.ts +1676 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCards.test.ts +2884 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.test.ts +2490 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmit.test.ts +2293 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateSubmitServerTimezone.test.ts +386 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.test.ts +1477 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.test.ts +1967 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsStripHtmlTags.test.ts +92 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts +1819 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.test.ts +1033 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezoneServerZone.test.ts +610 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsBotMessageHandling.test.ts +2610 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsCreateCommandsEndToEnd.test.ts +4672 -0
- package/Tests/Server/Utils/Workspace/WorkspaceCreateProjectReferences.test.ts +190 -65
- package/Tests/Types/AutoRemediation/AiRemediationCommandPlan.test.ts +10 -4
- package/Tests/Types/AutoRemediation/AiRemediationCommandPlanResourceCommand.test.ts +305 -0
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +277 -13
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +113 -0
- package/Tests/Types/Monitor/RumAlertTemplates.test.ts +773 -2
- package/Tests/Types/ResourceAiAgent/AiResourceType.test.ts +419 -0
- package/Tests/Types/ResourceAiAgent/ResourceAiAccess.test.ts +517 -0
- package/Tests/Types/Runbook/RunbookStepType.test.ts +147 -8
- package/Tests/UI/Rum/RecordingHealthDashboard.test.tsx +769 -1
- package/Tests/Utils/AiRemediation/Resource/CephCommandPolicy.test.ts +1653 -0
- package/Tests/Utils/AiRemediation/Resource/CephOutputRedaction.test.ts +204 -0
- package/Tests/Utils/AiRemediation/Resource/DatabaseCommandPolicy.test.ts +1077 -0
- package/Tests/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.test.ts +558 -0
- package/Tests/Utils/AiRemediation/Resource/DatabaseQueryRedactor.test.ts +834 -0
- package/Tests/Utils/AiRemediation/Resource/DockerCliGrammar.test.ts +747 -0
- package/Tests/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.test.ts +1259 -0
- package/Tests/Utils/AiRemediation/Resource/DockerOutputRedaction.test.ts +386 -0
- package/Tests/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.test.ts +782 -0
- package/Tests/Utils/AiRemediation/Resource/GovcCommandPolicy.test.ts +2259 -0
- package/Tests/Utils/AiRemediation/Resource/HostCommandPolicy.test.ts +2019 -0
- package/Tests/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.test.ts +2497 -0
- package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicy.test.ts +1305 -0
- package/Tests/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.test.ts +585 -0
- package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactor.test.ts +334 -0
- package/Tests/Utils/AiRemediation/Resource/ResourceOutputRedactorCopyParity.test.ts +98 -0
- package/Tests/Utils/AiRemediation/Resource/ResourcePolicyImportClosure.test.ts +391 -0
- package/Tests/Utils/AiRemediation/ResourceAiAgentPolicyCopyParity.test.ts +497 -0
- package/Tests/Utils/SessionReplay/SessionReplayBudgetMetricType.test.ts +383 -0
- package/Types/AI/ResourceAiAccessApi.ts +140 -0
- package/Types/AI/ResourceAiAccessPermissions.ts +126 -0
- package/Types/AutoRemediation/AiRemediationCommandPlan.ts +183 -2
- package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +55 -22
- package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +35 -3
- package/Types/Monitor/RumAlertTemplates.ts +351 -4
- package/Types/ResourceAiAgent/AiResourceType.ts +310 -0
- package/Types/ResourceAiAgent/ResourceAiAccess.ts +574 -0
- package/Types/Rum/SessionReplayBudgetMetricType.ts +47 -0
- package/Types/Runbook/RunbookStepType.ts +29 -0
- package/Types/Telemetry/TelemetryIngestSurface.ts +14 -2
- package/Utils/AI/InvestigationReport.ts +121 -15
- package/Utils/AiRemediation/Resource/CephCommandPolicy.ts +1936 -0
- package/Utils/AiRemediation/Resource/DatabaseCommandPolicy.ts +166 -0
- package/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.ts +1532 -0
- package/Utils/AiRemediation/Resource/DatabaseQueryRedactor.ts +1394 -0
- package/Utils/AiRemediation/Resource/DockerCliGrammar.ts +1839 -0
- package/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.ts +490 -0
- package/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.ts +687 -0
- package/Utils/AiRemediation/Resource/GovcCommandPolicy.ts +2096 -0
- package/Utils/AiRemediation/Resource/HostCommandPolicy.ts +2982 -0
- package/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.ts +1679 -0
- package/Utils/AiRemediation/Resource/ResourceCommandPolicy.ts +851 -0
- package/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.ts +593 -0
- package/Utils/AiRemediation/Resource/ResourceOutputRedactor.ts +2812 -0
- package/Utils/SessionReplay/SessionReplayBudgetMetricType.ts +184 -0
- package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js +62 -0
- package/build/dist/Models/DatabaseModels/AutoRemediationSuggestion.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CephCluster.js +219 -0
- package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DatabaseServer.js +219 -0
- package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js +219 -0
- package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +219 -0
- package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalConfig.js +1 -1
- package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Host.js +219 -0
- package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/PodmanHost.js +219 -0
- package/build/dist/Models/DatabaseModels/PodmanHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +219 -0
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ResourceAiAgent.js +439 -0
- package/build/dist/Models/DatabaseModels/ResourceAiAgent.js.map +1 -0
- package/build/dist/Models/DatabaseModels/RunnerJob.js +145 -0
- package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
- package/build/dist/Models/DatabaseModels/VMwareVCenter.js +219 -0
- package/build/dist/Models/DatabaseModels/VMwareVCenter.js.map +1 -1
- package/build/dist/Server/API/AutoRemediationAPI.js +231 -1
- package/build/dist/Server/API/AutoRemediationAPI.js.map +1 -1
- package/build/dist/Server/API/ResourceAiAccessAPI.js +1119 -0
- package/build/dist/Server/API/ResourceAiAccessAPI.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js +168 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1796300000000-AddResourceAiAgents.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Semaphore.js +6 -0
- package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
- package/build/dist/Server/Middleware/TelemetryIngest.js +12 -5
- package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +26 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +601 -3
- package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterService.js +104 -0
- package/build/dist/Server/Services/CephClusterService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseServerService.js +99 -0
- package/build/dist/Server/Services/DatabaseServerService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostService.js +104 -0
- package/build/dist/Server/Services/DockerHostService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterService.js +104 -0
- package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
- package/build/dist/Server/Services/HostService.js +114 -2
- package/build/dist/Server/Services/HostService.js.map +1 -1
- package/build/dist/Server/Services/MetricRecordingRuleService.js +46 -0
- package/build/dist/Server/Services/MetricRecordingRuleService.js.map +1 -1
- package/build/dist/Server/Services/PodmanHostService.js +104 -0
- package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxClusterService.js +104 -0
- package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
- package/build/dist/Server/Services/ResourceAiAccessService.js +1028 -0
- package/build/dist/Server/Services/ResourceAiAccessService.js.map +1 -0
- package/build/dist/Server/Services/ResourceAiAgentJobService.js +173 -0
- package/build/dist/Server/Services/ResourceAiAgentJobService.js.map +1 -0
- package/build/dist/Server/Services/ResourceAiAgentService.js +1687 -0
- package/build/dist/Server/Services/ResourceAiAgentService.js.map +1 -0
- package/build/dist/Server/Services/RunnerJobService.js +344 -5
- package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryUsageBillingService.js +26 -0
- package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
- package/build/dist/Server/Services/TraceRecordingRuleService.js +37 -0
- package/build/dist/Server/Services/TraceRecordingRuleService.js.map +1 -1
- package/build/dist/Server/Services/VMwareVCenterService.js +104 -0
- package/build/dist/Server/Services/VMwareVCenterService.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js +1029 -30
- package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +633 -28
- package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js +21 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationPlanRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +330 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js +160 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessContext.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js +33 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAccessToolNames.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js +640 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiAccessSettings.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js +226 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceAiDeleteCleanup.js.map +1 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +600 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -0
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +50 -4
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +55 -4
- package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +55 -4
- package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
- package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js +290 -13
- package/build/dist/Server/Utils/AutoRemediation/CommandPlanExecutor.js.map +1 -1
- package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js +29 -1
- package/build/dist/Server/Utils/AutoRemediation/RemediationVerifier.js.map +1 -1
- package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +9 -1
- package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js +627 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayBudgetMetrics.js.map +1 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js +60 -5
- package/build/dist/Server/Utils/SessionReplay/SessionReplayUsage.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +19 -15
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js +19 -15
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +17 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +183 -209
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +19 -15
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js +233 -167
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +263 -110
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js +129 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsActivityDeduplicator.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js +266 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCardChoices.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js +258 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsCreateCommands.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js +104 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsMessageSize.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js +183 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js +123 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsTimezone.js.map +1 -0
- package/build/dist/Types/AI/ResourceAiAccessApi.js +30 -0
- package/build/dist/Types/AI/ResourceAiAccessApi.js.map +1 -0
- package/build/dist/Types/AI/ResourceAiAccessPermissions.js +98 -0
- package/build/dist/Types/AI/ResourceAiAccessPermissions.js.map +1 -0
- package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js +116 -29
- package/build/dist/Types/AutoRemediation/AiRemediationCommandPlan.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +47 -21
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +5 -0
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
- package/build/dist/Types/Monitor/RumAlertTemplates.js +199 -3
- package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
- package/build/dist/Types/ResourceAiAgent/AiResourceType.js +242 -0
- package/build/dist/Types/ResourceAiAgent/AiResourceType.js.map +1 -0
- package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +275 -0
- package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -0
- package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js +45 -0
- package/build/dist/Types/Rum/SessionReplayBudgetMetricType.js.map +1 -0
- package/build/dist/Types/Runbook/RunbookStepType.js +27 -0
- package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
- package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +13 -2
- package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -1
- package/build/dist/Utils/AI/InvestigationReport.js +71 -11
- package/build/dist/Utils/AI/InvestigationReport.js.map +1 -1
- package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js +1462 -0
- package/build/dist/Utils/AiRemediation/Resource/CephCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js +122 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js +1139 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseDiagnosticCatalog.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js +1060 -0
- package/build/dist/Utils/AiRemediation/Resource/DatabaseQueryRedactor.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js +1301 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerCliGrammar.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js +370 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerEngineCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js +498 -0
- package/build/dist/Utils/AiRemediation/Resource/DockerSwarmCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js +1565 -0
- package/build/dist/Utils/AiRemediation/Resource/GovcCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js +2206 -0
- package/build/dist/Utils/AiRemediation/Resource/HostCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js +1129 -0
- package/build/dist/Utils/AiRemediation/Resource/ProxmoxCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js +565 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicy.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js +408 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceCommandPolicyCore.js.map +1 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js +1910 -0
- package/build/dist/Utils/AiRemediation/Resource/ResourceOutputRedactor.js.map +1 -0
- package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js +160 -0
- package/build/dist/Utils/SessionReplay/SessionReplayBudgetMetricType.js.map +1 -0
- package/package.json +1 -1
package/Tests/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsSubmittedFormRemoval.test.ts
ADDED
|
@@ -0,0 +1,1819 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
jest,
|
|
7
|
+
test,
|
|
8
|
+
} from "@jest/globals";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Issue #4111: in Microsoft Teams, "create incident" and "create maintenance"
|
|
12
|
+
* answered "Sorry, I encountered an error processing your request" - and
|
|
13
|
+
* every error bubble showed up twice, because a failing turn was answered
|
|
14
|
+
* with HTTP 500 and Teams delivered it again. The fix made every reply that
|
|
15
|
+
* is not the point of a turn best-effort, and that includes removing a form
|
|
16
|
+
* the user has just submitted.
|
|
17
|
+
*
|
|
18
|
+
* What this file pins: once a form's action has been done and confirmed,
|
|
19
|
+
* removing the submitted form ("Hide the form card") is a courtesy. Before
|
|
20
|
+
* the fix every form handler awaited turnContext.deleteActivity(replyToId)
|
|
21
|
+
* itself - after the success reply, or, in the two create forms, before it.
|
|
22
|
+
* When Teams refused the removal - the card was already gone (404
|
|
23
|
+
* ActivityNotFoundInConversation), the bot had been removed from the chat
|
|
24
|
+
* (403) - the rejection escaped:
|
|
25
|
+
*
|
|
26
|
+
* - the incident, alert, alert episode and incident episode handlers threw
|
|
27
|
+
* into handleBotInvokeActivity, which answered "Sorry, that action failed.
|
|
28
|
+
* Please try again later." right under "✅ Note added successfully.";
|
|
29
|
+
* - the scheduled maintenance handler caught it itself and answered "An
|
|
30
|
+
* error occurred while processing the action";
|
|
31
|
+
* - the create forms answered "❌ Failed to create incident. Please try
|
|
32
|
+
* again." (or "... scheduled maintenance ...") instead of the confirmation,
|
|
33
|
+
* for a record that had been created.
|
|
34
|
+
*
|
|
35
|
+
* Every time the user was told that something which had happened had failed,
|
|
36
|
+
* and pressed the button again: a second note, a second page of the on-call
|
|
37
|
+
* policy, a second incident. Every removal now goes through
|
|
38
|
+
* MicrosoftTeamsReplies.deleteBestEffort (logged at debug, never thrown),
|
|
39
|
+
* after the confirmation. The confirmation is sent best-effort as well
|
|
40
|
+
* (MicrosoftTeamsReplies.sendBestEffort): the 14 note, state and on-call
|
|
41
|
+
* submits used to send it themselves, so Teams refusing the confirmation
|
|
42
|
+
* threw the same way, before the form was removed.
|
|
43
|
+
*
|
|
44
|
+
* Every form submit that removes its form is driven here through its real
|
|
45
|
+
* handler - the 14 "Hide the form card" sites and the two create forms (whose
|
|
46
|
+
* create flow MicrosoftTeamsCreateSubmit.test.ts covers in depth) - with
|
|
47
|
+
* Teams refusing the removal, removing it, answering it only later, and with
|
|
48
|
+
* no form to remove; then once more through handleBotInvokeActivity, where
|
|
49
|
+
* "Sorry, that action failed" would come from. The rest of the contract
|
|
50
|
+
* around the removal is pinned as well:
|
|
51
|
+
*
|
|
52
|
+
* - the handler returns only once Teams has answered the removal: botbuilder
|
|
53
|
+
* revokes the TurnContext when the turn ends, so a removal still in flight
|
|
54
|
+
* would fail and leave the form on screen, ready to be submitted twice;
|
|
55
|
+
* - a submit whose action failed keeps its form, so the user can submit it
|
|
56
|
+
* again, and gets exactly one reply saying it failed - for a create, with
|
|
57
|
+
* the dashboard page to create it in instead when that link can be built -
|
|
58
|
+
* while the log gets the failure and the error itself;
|
|
59
|
+
* - a create refused because a value it picked is not in the project (deleted
|
|
60
|
+
* since the form was sent, or another project's in a tampered submit) says
|
|
61
|
+
* so in fixed words, which never name the other project's record, and keeps
|
|
62
|
+
* its form, so the value can be picked again;
|
|
63
|
+
* - an action done whose confirmation Teams refuses still has its form
|
|
64
|
+
* removed, and gets no failure reply: the form must not invite a second
|
|
65
|
+
* submit.
|
|
66
|
+
*
|
|
67
|
+
* The services each action writes to are stubbed, so no database is touched.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
jest.mock("../../../../../Server/EnvironmentConfig", () => {
|
|
71
|
+
return {
|
|
72
|
+
...(jest.requireActual("../../../../../Server/EnvironmentConfig") as Record<
|
|
73
|
+
string,
|
|
74
|
+
unknown
|
|
75
|
+
>),
|
|
76
|
+
MicrosoftTeamsAppClientId: "11111111-2222-3333-4444-555555555555",
|
|
77
|
+
MicrosoftTeamsAppClientSecret: "test-secret",
|
|
78
|
+
MicrosoftTeamsAppTenantId: "test-tenant",
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/*
|
|
83
|
+
* Same botbuilder module factory as MicrosoftTeamsTeamInstalls.test.ts: the
|
|
84
|
+
* repo-wide manual mock (Tests/__mocks__/botbuilder.js) does not expose
|
|
85
|
+
* TeamsInfo or MessageFactory.attachment, both of which MicrosoftTeams.ts
|
|
86
|
+
* touches at import time.
|
|
87
|
+
*/
|
|
88
|
+
jest.mock("botbuilder", () => {
|
|
89
|
+
return {
|
|
90
|
+
CloudAdapter: class CloudAdapter {},
|
|
91
|
+
ConfigurationBotFrameworkAuthentication: class ConfigurationBotFrameworkAuthentication {},
|
|
92
|
+
TeamsActivityHandler: class TeamsActivityHandler {},
|
|
93
|
+
TurnContext: class TurnContext {},
|
|
94
|
+
ActivityHandler: class ActivityHandler {},
|
|
95
|
+
MessageFactory: {
|
|
96
|
+
text: jest.fn(),
|
|
97
|
+
attachment: jest.fn((attachment: unknown) => {
|
|
98
|
+
return { type: "message", attachments: [attachment] };
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
CardFactory: { heroCard: jest.fn() },
|
|
102
|
+
TeamsInfo: {
|
|
103
|
+
getMembers: jest.fn(),
|
|
104
|
+
getPagedMembers: jest.fn(),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
import type { TurnContext } from "botbuilder";
|
|
110
|
+
import fs from "fs";
|
|
111
|
+
import type { SpyInstance } from "jest-mock";
|
|
112
|
+
import path from "path";
|
|
113
|
+
import AlertEpisodeInternalNote from "../../../../../Models/DatabaseModels/AlertEpisodeInternalNote";
|
|
114
|
+
import AlertInternalNote from "../../../../../Models/DatabaseModels/AlertInternalNote";
|
|
115
|
+
import Incident from "../../../../../Models/DatabaseModels/Incident";
|
|
116
|
+
import IncidentEpisodeInternalNote from "../../../../../Models/DatabaseModels/IncidentEpisodeInternalNote";
|
|
117
|
+
import IncidentInternalNote from "../../../../../Models/DatabaseModels/IncidentInternalNote";
|
|
118
|
+
import IncidentPublicNote from "../../../../../Models/DatabaseModels/IncidentPublicNote";
|
|
119
|
+
import Monitor from "../../../../../Models/DatabaseModels/Monitor";
|
|
120
|
+
import ScheduledMaintenance from "../../../../../Models/DatabaseModels/ScheduledMaintenance";
|
|
121
|
+
import ScheduledMaintenanceInternalNote from "../../../../../Models/DatabaseModels/ScheduledMaintenanceInternalNote";
|
|
122
|
+
import ScheduledMaintenancePublicNote from "../../../../../Models/DatabaseModels/ScheduledMaintenancePublicNote";
|
|
123
|
+
import WorkspaceProjectAuthToken from "../../../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
|
|
124
|
+
import DatabaseConfig from "../../../../../Server/DatabaseConfig";
|
|
125
|
+
import AlertEpisodeInternalNoteService from "../../../../../Server/Services/AlertEpisodeInternalNoteService";
|
|
126
|
+
import AlertEpisodeService from "../../../../../Server/Services/AlertEpisodeService";
|
|
127
|
+
import AlertInternalNoteService from "../../../../../Server/Services/AlertInternalNoteService";
|
|
128
|
+
import AlertService from "../../../../../Server/Services/AlertService";
|
|
129
|
+
import IncidentEpisodeInternalNoteService from "../../../../../Server/Services/IncidentEpisodeInternalNoteService";
|
|
130
|
+
import IncidentEpisodeService from "../../../../../Server/Services/IncidentEpisodeService";
|
|
131
|
+
import IncidentInternalNoteService from "../../../../../Server/Services/IncidentInternalNoteService";
|
|
132
|
+
import IncidentPublicNoteService from "../../../../../Server/Services/IncidentPublicNoteService";
|
|
133
|
+
import IncidentService from "../../../../../Server/Services/IncidentService";
|
|
134
|
+
import MonitorService from "../../../../../Server/Services/MonitorService";
|
|
135
|
+
import OnCallDutyPolicyService from "../../../../../Server/Services/OnCallDutyPolicyService";
|
|
136
|
+
import ScheduledMaintenanceInternalNoteService from "../../../../../Server/Services/ScheduledMaintenanceInternalNoteService";
|
|
137
|
+
import ScheduledMaintenancePublicNoteService from "../../../../../Server/Services/ScheduledMaintenancePublicNoteService";
|
|
138
|
+
import ScheduledMaintenanceService from "../../../../../Server/Services/ScheduledMaintenanceService";
|
|
139
|
+
import { ProjectScopedReferenceException } from "../../../../../Server/Utils/Database/ProjectScopedReferenceValidator";
|
|
140
|
+
import logger, { LogAttributes } from "../../../../../Server/Utils/Logger";
|
|
141
|
+
import {
|
|
142
|
+
MicrosoftTeamsAlertActionType,
|
|
143
|
+
MicrosoftTeamsAlertEpisodeActionType,
|
|
144
|
+
MicrosoftTeamsIncidentActionType,
|
|
145
|
+
MicrosoftTeamsIncidentEpisodeActionType,
|
|
146
|
+
MicrosoftTeamsScheduledMaintenanceActionType,
|
|
147
|
+
} from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/ActionTypes";
|
|
148
|
+
import MicrosoftTeamsAlertActions from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/Alert";
|
|
149
|
+
import MicrosoftTeamsAlertEpisodeActions from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/AlertEpisode";
|
|
150
|
+
import MicrosoftTeamsAuthAction, {
|
|
151
|
+
MicrosoftTeamsRequest,
|
|
152
|
+
} from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/Auth";
|
|
153
|
+
import MicrosoftTeamsIncidentActions from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/Incident";
|
|
154
|
+
import MicrosoftTeamsIncidentEpisodeActions from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode";
|
|
155
|
+
import MicrosoftTeamsScheduledMaintenanceActions from "../../../../../Server/Utils/Workspace/MicrosoftTeams/Actions/ScheduledMaintenance";
|
|
156
|
+
import MicrosoftTeamsUtil from "../../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
|
|
157
|
+
import MicrosoftTeamsReplies from "../../../../../Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeamsReplies";
|
|
158
|
+
import WorkspaceActionAuthorization from "../../../../../Server/Utils/Workspace/WorkspaceActionAuthorization";
|
|
159
|
+
import WorkspaceProjectReferenceValidator from "../../../../../Server/Utils/Workspace/WorkspaceProjectReferenceValidator";
|
|
160
|
+
import URL from "../../../../../Types/API/URL";
|
|
161
|
+
import DatabaseCommonInteractionProps from "../../../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
162
|
+
import { JSONObject } from "../../../../../Types/JSON";
|
|
163
|
+
import ObjectID from "../../../../../Types/ObjectID";
|
|
164
|
+
import UserNotificationEventType from "../../../../../Types/UserNotification/UserNotificationEventType";
|
|
165
|
+
|
|
166
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
167
|
+
"6f1d2c3b-4a59-4e68-8d7c-1b2a3c4d5e6f",
|
|
168
|
+
);
|
|
169
|
+
const USER_ID: ObjectID = new ObjectID("2b3c4d5e-6f70-4a81-9b2c-3d4e5f607182");
|
|
170
|
+
|
|
171
|
+
// The Teams sender (from.aadObjectId) and the tenant the bot resolves.
|
|
172
|
+
const TEAMS_USER_ID: string = "3f2e1d0c-9b8a-4776-8554-433221100fed";
|
|
173
|
+
const TENANT_ID: string = "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d";
|
|
174
|
+
const BOT_ID: string = "28:oneuptime-bot";
|
|
175
|
+
|
|
176
|
+
// The form card the user filled in (the submit's replyToId) and the submit.
|
|
177
|
+
const FORM_ACTIVITY_ID: string = "1727611200123";
|
|
178
|
+
const SUBMIT_ACTIVITY_ID: string = "1727611260456";
|
|
179
|
+
|
|
180
|
+
const INCIDENT_ID: string = "0a1b2c3d-4e5f-4a6b-8c7d-8e9f0a1b2c3d";
|
|
181
|
+
const ALERT_ID: string = "1b2c3d4e-5f6a-4b7c-9d8e-9f0a1b2c3d4e";
|
|
182
|
+
const ALERT_EPISODE_ID: string = "2c3d4e5f-6a7b-4c8d-8e9f-0a1b2c3d4e5f";
|
|
183
|
+
const INCIDENT_EPISODE_ID: string = "3d4e5f6a-7b8c-4d9e-9f0a-1b2c3d4e5f6a";
|
|
184
|
+
const MAINTENANCE_ID: string = "4e5f6a7b-8c9d-4e0f-8a1b-2c3d4e5f6a7b";
|
|
185
|
+
const ON_CALL_POLICY_ID: string = "5f6a7b8c-9d0e-4f1a-9b2c-3d4e5f6a7b8c";
|
|
186
|
+
const INCIDENT_STATE_ID: string = "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d";
|
|
187
|
+
const ALERT_STATE_ID: string = "7b8c9d0e-1f2a-4b3c-9d4e-5f6a7b8c9d0e";
|
|
188
|
+
const MAINTENANCE_STATE_ID: string = "8c9d0e1f-2a3b-4c4d-8e5f-6a7b8c9d0e1f";
|
|
189
|
+
const SEVERITY_ID: string = "9d0e1f2a-3b4c-4d5e-9f6a-7b8c9d0e1f2a";
|
|
190
|
+
const CREATED_ID: ObjectID = new ObjectID(
|
|
191
|
+
"ae1f2a3b-4c5d-4e6f-8a7b-8c9d0e1f2a3b",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const NOTE: string = "Rolled back the 14:02 deploy; the error rate is normal.";
|
|
195
|
+
|
|
196
|
+
// The member's own permissions, as getProjectMemberProps resolves them.
|
|
197
|
+
const MEMBER_PROPS: DatabaseCommonInteractionProps = {
|
|
198
|
+
userId: USER_ID,
|
|
199
|
+
tenantId: PROJECT_ID,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// What handleBotInvokeActivity hands the scheduled maintenance handler.
|
|
203
|
+
const MAINTENANCE_REQUEST: MicrosoftTeamsRequest = {
|
|
204
|
+
isAuthorized: true,
|
|
205
|
+
projectId: PROJECT_ID,
|
|
206
|
+
authToken: "",
|
|
207
|
+
payloadType: "invoke",
|
|
208
|
+
userId: USER_ID.toString(),
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// The dashboard, as DatabaseConfig.getDashboardUrl answers in every test.
|
|
212
|
+
const DASHBOARD_URL: string = "https://oneuptime.test/dashboard";
|
|
213
|
+
|
|
214
|
+
const INCIDENT_LINK: string = `${DASHBOARD_URL}/${PROJECT_ID.toString()}/incidents/${CREATED_ID.toString()}`;
|
|
215
|
+
const MAINTENANCE_LINK: string = `${DASHBOARD_URL}/${PROJECT_ID.toString()}/scheduled-maintenance-events/${CREATED_ID.toString()}`;
|
|
216
|
+
|
|
217
|
+
// Replies that would tell the user a done action failed.
|
|
218
|
+
const FAILURE_TEXTS: ReadonlyArray<string> = [
|
|
219
|
+
"Sorry, that action failed",
|
|
220
|
+
"An error occurred while processing the action",
|
|
221
|
+
"Sorry, something went wrong",
|
|
222
|
+
"❌",
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
// The one reply a user gets when the action itself failed unexpectedly.
|
|
226
|
+
const ACTION_FAILED: string =
|
|
227
|
+
"Sorry, that action failed. Please try again later.";
|
|
228
|
+
const MAINTENANCE_ACTION_FAILED: string =
|
|
229
|
+
"An error occurred while processing the action";
|
|
230
|
+
|
|
231
|
+
/*
|
|
232
|
+
* A create that failed unexpectedly links the dashboard page where the record
|
|
233
|
+
* can be created instead, or ends there when no link can be built.
|
|
234
|
+
*/
|
|
235
|
+
const INCIDENT_CREATE_FAILED: 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`;
|
|
236
|
+
const INCIDENT_CREATE_FAILED_WITHOUT_LINK: string =
|
|
237
|
+
"❌ Could not create the incident because of an unexpected error. Please try again, or create it in OneUptime.";
|
|
238
|
+
const MAINTENANCE_CREATE_FAILED: 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`;
|
|
239
|
+
const MAINTENANCE_CREATE_FAILED_WITHOUT_LINK: string =
|
|
240
|
+
"❌ Could not create the scheduled maintenance event because of an unexpected error. Please try again, or create it in OneUptime.";
|
|
241
|
+
|
|
242
|
+
/*
|
|
243
|
+
* Why a create was refused when a value it picked is not in the project.
|
|
244
|
+
* Fixed words: the project check's own message names the record, which may
|
|
245
|
+
* be another project's.
|
|
246
|
+
*/
|
|
247
|
+
const UNAVAILABLE_REFERENCE: string =
|
|
248
|
+
"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.";
|
|
249
|
+
|
|
250
|
+
// A monitor of another project, as a tampered submit could name it.
|
|
251
|
+
const FOREIGN_MONITOR_ID: string = "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e";
|
|
252
|
+
const FOREIGN_MONITOR_NAME: string = "Acme Corp: payments-db-primary";
|
|
253
|
+
const OTHER_PROJECT_ID: ObjectID = new ObjectID(
|
|
254
|
+
"c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// The debug line deleteBestEffort logs for a removal Teams refused.
|
|
258
|
+
const REMOVAL_REFUSED_LOG_PREFIX: string =
|
|
259
|
+
"Could not remove a submitted Microsoft Teams card: ";
|
|
260
|
+
|
|
261
|
+
// The error line sendBestEffort logs for a reply Teams refused.
|
|
262
|
+
const REPLY_REFUSED_LOG_PREFIX: string =
|
|
263
|
+
"Could not send a Microsoft Teams reply: ";
|
|
264
|
+
|
|
265
|
+
type TurnEvent =
|
|
266
|
+
| { kind: "reply"; text: string }
|
|
267
|
+
| { kind: "delete"; activityId: string };
|
|
268
|
+
|
|
269
|
+
interface FakeTurn {
|
|
270
|
+
turnContext: TurnContext;
|
|
271
|
+
// Every reply and every removal the code asked Teams for, in order.
|
|
272
|
+
events: Array<TurnEvent>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/*
|
|
276
|
+
* A TurnContext for one submit. Teams takes every reply unless `replyError`
|
|
277
|
+
* is given, and removes a message unless `deleteError` is given; a refused
|
|
278
|
+
* reply or removal is still recorded: it was attempted. With
|
|
279
|
+
* `removalAnswered`, Teams answers a removal only once that promise settles.
|
|
280
|
+
*/
|
|
281
|
+
function createFakeTurn(data: {
|
|
282
|
+
activity: JSONObject;
|
|
283
|
+
replyError?: unknown;
|
|
284
|
+
deleteError?: unknown;
|
|
285
|
+
removalAnswered?: Promise<void> | undefined;
|
|
286
|
+
}): FakeTurn {
|
|
287
|
+
const events: Array<TurnEvent> = [];
|
|
288
|
+
|
|
289
|
+
const turnContext: TurnContext = {
|
|
290
|
+
activity: data.activity,
|
|
291
|
+
sendActivity: async (reply: unknown): Promise<{ id: string }> => {
|
|
292
|
+
events.push({
|
|
293
|
+
kind: "reply",
|
|
294
|
+
text: typeof reply === "string" ? reply : JSON.stringify(reply),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (data.replyError !== undefined) {
|
|
298
|
+
throw data.replyError;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { id: `reply-${events.length}` };
|
|
302
|
+
},
|
|
303
|
+
deleteActivity: async (activityId: string): Promise<void> => {
|
|
304
|
+
events.push({ kind: "delete", activityId: activityId });
|
|
305
|
+
|
|
306
|
+
if (data.removalAnswered) {
|
|
307
|
+
await data.removalAnswered;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (data.deleteError !== undefined) {
|
|
311
|
+
throw data.deleteError;
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
} as unknown as TurnContext;
|
|
315
|
+
|
|
316
|
+
return { turnContext: turnContext, events: events };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// A removal Teams answers only when the test says so.
|
|
320
|
+
interface DelayedRemoval {
|
|
321
|
+
answered: Promise<void>;
|
|
322
|
+
answer: () => void;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function delayedRemoval(): DelayedRemoval {
|
|
326
|
+
let answer: () => void = (): void => {};
|
|
327
|
+
const answered: Promise<void> = new Promise<void>(
|
|
328
|
+
(resolve: (value: void) => void): void => {
|
|
329
|
+
answer = (): void => {
|
|
330
|
+
resolve();
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
answered: answered,
|
|
337
|
+
answer: (): void => {
|
|
338
|
+
answer();
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/*
|
|
344
|
+
* Settles after every promise callback already queued has run: a timer fires
|
|
345
|
+
* only once the microtask queue is empty. No time passes for the code under
|
|
346
|
+
* test - everything it awaits here is a stub that resolves in microtasks.
|
|
347
|
+
*/
|
|
348
|
+
function afterQueuedCallbacks(): Promise<void> {
|
|
349
|
+
return new Promise<void>((resolve: (value: void) => void): void => {
|
|
350
|
+
setTimeout(resolve, 0);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// What botbuilder throws when the Bot Connector refuses a call.
|
|
355
|
+
function teamsRefusal(data: {
|
|
356
|
+
statusCode: number;
|
|
357
|
+
code: string;
|
|
358
|
+
message: string;
|
|
359
|
+
}): Error {
|
|
360
|
+
return Object.assign(new Error(data.message), {
|
|
361
|
+
name: "RestError",
|
|
362
|
+
statusCode: data.statusCode,
|
|
363
|
+
code: data.code,
|
|
364
|
+
details: { error: { code: data.code, message: data.message } },
|
|
365
|
+
response: { status: data.statusCode },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The form was removed already, e.g. by a second click on its button.
|
|
370
|
+
function formAlreadyGone(): Error {
|
|
371
|
+
return teamsRefusal({
|
|
372
|
+
statusCode: 404,
|
|
373
|
+
code: "ActivityNotFoundInConversation",
|
|
374
|
+
message: "The activity was not found in the conversation.",
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The write the form asked for could not reach the database.
|
|
379
|
+
function databaseUnreachable(): Error {
|
|
380
|
+
return new Error("connect ECONNREFUSED 10.0.0.5:5432");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function repliesOf(turn: FakeTurn): Array<string> {
|
|
384
|
+
return turn.events
|
|
385
|
+
.filter((event: TurnEvent) => {
|
|
386
|
+
return event.kind === "reply";
|
|
387
|
+
})
|
|
388
|
+
.map((event: TurnEvent) => {
|
|
389
|
+
return event.kind === "reply" ? event.text : "";
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function expectNoFailureReply(turn: FakeTurn): void {
|
|
394
|
+
for (const reply of repliesOf(turn)) {
|
|
395
|
+
for (const failureText of FAILURE_TEXTS) {
|
|
396
|
+
expect(reply).not.toContain(failureText);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/*
|
|
402
|
+
* What a stubbed write answers: its result, or - when the test makes the
|
|
403
|
+
* action fail - a rejection.
|
|
404
|
+
*/
|
|
405
|
+
function writeOutcome<T>(result: T, failure: Error | undefined): Promise<T> {
|
|
406
|
+
if (failure) {
|
|
407
|
+
return Promise.reject(failure);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return Promise.resolve(result);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
type HandlerFamily =
|
|
414
|
+
| "incident"
|
|
415
|
+
| "alert"
|
|
416
|
+
| "alert episode"
|
|
417
|
+
| "incident episode"
|
|
418
|
+
| "scheduled maintenance";
|
|
419
|
+
|
|
420
|
+
// Asserts that the write a form asked for was attempted, once, as asked.
|
|
421
|
+
type WriteCheck = () => void;
|
|
422
|
+
|
|
423
|
+
interface SubmittedForm {
|
|
424
|
+
name: string;
|
|
425
|
+
family: HandlerFamily;
|
|
426
|
+
actionType: string;
|
|
427
|
+
// The record the form acts on; maintenance forms carry theirs in `fields`.
|
|
428
|
+
actionValue: string;
|
|
429
|
+
// What the user filled in, as the submit's value carries it.
|
|
430
|
+
fields: JSONObject;
|
|
431
|
+
// The reply that says it worked.
|
|
432
|
+
confirmation: string;
|
|
433
|
+
// The one reply, through handleBotInvokeActivity, when the write fails.
|
|
434
|
+
failureReply: string;
|
|
435
|
+
// Stubs the write the form asks for; it rejects with `failure` if given.
|
|
436
|
+
stubWrite: (failure?: Error) => WriteCheck;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The two submits that create a record rather than change one.
|
|
440
|
+
const CREATE_SUBMITS: ReadonlyArray<string> = [
|
|
441
|
+
MicrosoftTeamsIncidentActionType.SubmitNewIncident,
|
|
442
|
+
MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
|
|
443
|
+
];
|
|
444
|
+
|
|
445
|
+
function stubExistingMaintenance(): void {
|
|
446
|
+
const maintenance: ScheduledMaintenance = new ScheduledMaintenance();
|
|
447
|
+
maintenance.id = new ObjectID(MAINTENANCE_ID);
|
|
448
|
+
maintenance.projectId = PROJECT_ID;
|
|
449
|
+
maintenance.title = "Database upgrade";
|
|
450
|
+
|
|
451
|
+
jest
|
|
452
|
+
.spyOn(ScheduledMaintenanceService, "findOneBy")
|
|
453
|
+
.mockResolvedValue(maintenance);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function stubExecutePolicy(expected: {
|
|
457
|
+
trigger: JSONObject;
|
|
458
|
+
userNotificationEventType: UserNotificationEventType;
|
|
459
|
+
failure: Error | undefined;
|
|
460
|
+
}): WriteCheck {
|
|
461
|
+
const executePolicy: SpyInstance<
|
|
462
|
+
typeof OnCallDutyPolicyService.executePolicy
|
|
463
|
+
> = jest
|
|
464
|
+
.spyOn(OnCallDutyPolicyService, "executePolicy")
|
|
465
|
+
.mockImplementation((): Promise<void> => {
|
|
466
|
+
return writeOutcome(undefined, expected.failure);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return (): void => {
|
|
470
|
+
expect(executePolicy).toHaveBeenCalledTimes(1);
|
|
471
|
+
expect(executePolicy).toHaveBeenCalledWith(
|
|
472
|
+
new ObjectID(ON_CALL_POLICY_ID),
|
|
473
|
+
{
|
|
474
|
+
...expected.trigger,
|
|
475
|
+
userNotificationEventType: expected.userNotificationEventType,
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function firstArgument(spy: {
|
|
482
|
+
mock: { calls: Array<Array<unknown>> };
|
|
483
|
+
}): JSONObject {
|
|
484
|
+
expect(spy.mock.calls).toHaveLength(1);
|
|
485
|
+
return spy.mock.calls[0]![0] as JSONObject;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Every form submit in Actions/ that removes its form once it is done.
|
|
489
|
+
const SUBMITTED_FORMS: ReadonlyArray<SubmittedForm> = [
|
|
490
|
+
{
|
|
491
|
+
name: "incident: Add Note (public)",
|
|
492
|
+
family: "incident",
|
|
493
|
+
actionType: MicrosoftTeamsIncidentActionType.SubmitIncidentNote,
|
|
494
|
+
actionValue: INCIDENT_ID,
|
|
495
|
+
fields: { noteType: "public", note: NOTE },
|
|
496
|
+
confirmation: "✅ Note added successfully.",
|
|
497
|
+
failureReply: ACTION_FAILED,
|
|
498
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
499
|
+
const addNote: SpyInstance<typeof IncidentPublicNoteService.addNote> =
|
|
500
|
+
jest
|
|
501
|
+
.spyOn(IncidentPublicNoteService, "addNote")
|
|
502
|
+
.mockImplementation((): Promise<IncidentPublicNote> => {
|
|
503
|
+
return writeOutcome(new IncidentPublicNote(), failure);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
return (): void => {
|
|
507
|
+
expect(addNote).toHaveBeenCalledTimes(1);
|
|
508
|
+
expect(addNote).toHaveBeenCalledWith({
|
|
509
|
+
incidentId: new ObjectID(INCIDENT_ID),
|
|
510
|
+
note: NOTE,
|
|
511
|
+
projectId: PROJECT_ID,
|
|
512
|
+
userId: USER_ID,
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
name: "incident: Add Note (private)",
|
|
519
|
+
family: "incident",
|
|
520
|
+
actionType: MicrosoftTeamsIncidentActionType.SubmitIncidentNote,
|
|
521
|
+
actionValue: INCIDENT_ID,
|
|
522
|
+
fields: { noteType: "private", note: NOTE },
|
|
523
|
+
confirmation: "✅ Note added successfully.",
|
|
524
|
+
failureReply: ACTION_FAILED,
|
|
525
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
526
|
+
const addNote: SpyInstance<typeof IncidentInternalNoteService.addNote> =
|
|
527
|
+
jest
|
|
528
|
+
.spyOn(IncidentInternalNoteService, "addNote")
|
|
529
|
+
.mockImplementation((): Promise<IncidentInternalNote> => {
|
|
530
|
+
return writeOutcome(new IncidentInternalNote(), failure);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
return (): void => {
|
|
534
|
+
expect(addNote).toHaveBeenCalledTimes(1);
|
|
535
|
+
expect(addNote).toHaveBeenCalledWith({
|
|
536
|
+
incidentId: new ObjectID(INCIDENT_ID),
|
|
537
|
+
note: NOTE,
|
|
538
|
+
projectId: PROJECT_ID,
|
|
539
|
+
userId: USER_ID,
|
|
540
|
+
});
|
|
541
|
+
};
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
name: "incident: Execute On-Call Policy",
|
|
546
|
+
family: "incident",
|
|
547
|
+
actionType:
|
|
548
|
+
MicrosoftTeamsIncidentActionType.SubmitExecuteIncidentOnCallPolicy,
|
|
549
|
+
actionValue: INCIDENT_ID,
|
|
550
|
+
fields: { onCallPolicy: ON_CALL_POLICY_ID },
|
|
551
|
+
confirmation: "✅ On-call policy executed successfully.",
|
|
552
|
+
failureReply: ACTION_FAILED,
|
|
553
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
554
|
+
return stubExecutePolicy({
|
|
555
|
+
trigger: { triggeredByIncidentId: new ObjectID(INCIDENT_ID) },
|
|
556
|
+
userNotificationEventType: UserNotificationEventType.IncidentCreated,
|
|
557
|
+
failure: failure,
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: "incident: Change Incident State",
|
|
563
|
+
family: "incident",
|
|
564
|
+
actionType: MicrosoftTeamsIncidentActionType.SubmitChangeIncidentState,
|
|
565
|
+
actionValue: INCIDENT_ID,
|
|
566
|
+
fields: { incidentState: INCIDENT_STATE_ID },
|
|
567
|
+
confirmation: "✅ Incident state changed successfully.",
|
|
568
|
+
failureReply: ACTION_FAILED,
|
|
569
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
570
|
+
const updateOneById: SpyInstance<typeof IncidentService.updateOneById> =
|
|
571
|
+
jest
|
|
572
|
+
.spyOn(IncidentService, "updateOneById")
|
|
573
|
+
.mockImplementation((): Promise<number> => {
|
|
574
|
+
return writeOutcome(1, failure);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return (): void => {
|
|
578
|
+
expect(updateOneById).toHaveBeenCalledTimes(1);
|
|
579
|
+
expect(updateOneById).toHaveBeenCalledWith({
|
|
580
|
+
id: new ObjectID(INCIDENT_ID),
|
|
581
|
+
data: { currentIncidentStateId: new ObjectID(INCIDENT_STATE_ID) },
|
|
582
|
+
props: MEMBER_PROPS,
|
|
583
|
+
});
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: "incident: Create New Incident",
|
|
589
|
+
family: "incident",
|
|
590
|
+
actionType: MicrosoftTeamsIncidentActionType.SubmitNewIncident,
|
|
591
|
+
actionValue: "",
|
|
592
|
+
fields: {
|
|
593
|
+
incidentTitle: "Checkout is failing",
|
|
594
|
+
incidentDescription: "Payments time out after 30 seconds.",
|
|
595
|
+
incidentSeverity: SEVERITY_ID,
|
|
596
|
+
},
|
|
597
|
+
confirmation: `✅ Incident created successfully!\n\nView incident: ${INCIDENT_LINK}`,
|
|
598
|
+
failureReply: INCIDENT_CREATE_FAILED,
|
|
599
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
600
|
+
const created: Incident = new Incident();
|
|
601
|
+
created.id = CREATED_ID;
|
|
602
|
+
created.projectId = PROJECT_ID;
|
|
603
|
+
|
|
604
|
+
jest
|
|
605
|
+
.spyOn(
|
|
606
|
+
WorkspaceProjectReferenceValidator,
|
|
607
|
+
"validateReferencesBelongToProject",
|
|
608
|
+
)
|
|
609
|
+
.mockResolvedValue();
|
|
610
|
+
jest
|
|
611
|
+
.spyOn(IncidentService, "getIncidentLinkInDashboard")
|
|
612
|
+
.mockResolvedValue(URL.fromString(INCIDENT_LINK));
|
|
613
|
+
const create: SpyInstance<typeof IncidentService.create> = jest
|
|
614
|
+
.spyOn(IncidentService, "create")
|
|
615
|
+
.mockImplementation((): Promise<Incident> => {
|
|
616
|
+
return writeOutcome(created, failure);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
return (): void => {
|
|
620
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
621
|
+
const incident: Incident = create.mock.calls[0]![0].data;
|
|
622
|
+
expect(incident.title).toBe("Checkout is failing");
|
|
623
|
+
expect(incident.projectId).toEqual(PROJECT_ID);
|
|
624
|
+
expect(incident.createdByUserId).toEqual(USER_ID);
|
|
625
|
+
};
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
name: "alert: Add Note",
|
|
630
|
+
family: "alert",
|
|
631
|
+
actionType: MicrosoftTeamsAlertActionType.SubmitAlertNote,
|
|
632
|
+
actionValue: ALERT_ID,
|
|
633
|
+
fields: { note: NOTE },
|
|
634
|
+
confirmation: "✅ Note added successfully.",
|
|
635
|
+
failureReply: ACTION_FAILED,
|
|
636
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
637
|
+
const addNote: SpyInstance<typeof AlertInternalNoteService.addNote> = jest
|
|
638
|
+
.spyOn(AlertInternalNoteService, "addNote")
|
|
639
|
+
.mockImplementation((): Promise<AlertInternalNote> => {
|
|
640
|
+
return writeOutcome(new AlertInternalNote(), failure);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
return (): void => {
|
|
644
|
+
expect(addNote).toHaveBeenCalledTimes(1);
|
|
645
|
+
expect(addNote).toHaveBeenCalledWith({
|
|
646
|
+
alertId: new ObjectID(ALERT_ID),
|
|
647
|
+
note: NOTE,
|
|
648
|
+
projectId: PROJECT_ID,
|
|
649
|
+
userId: USER_ID,
|
|
650
|
+
});
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
name: "alert: Execute On-Call Policy",
|
|
656
|
+
family: "alert",
|
|
657
|
+
actionType: MicrosoftTeamsAlertActionType.SubmitExecuteAlertOnCallPolicy,
|
|
658
|
+
actionValue: ALERT_ID,
|
|
659
|
+
fields: { onCallPolicy: ON_CALL_POLICY_ID },
|
|
660
|
+
confirmation: "✅ On-call policy executed successfully.",
|
|
661
|
+
failureReply: ACTION_FAILED,
|
|
662
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
663
|
+
return stubExecutePolicy({
|
|
664
|
+
trigger: { triggeredByAlertId: new ObjectID(ALERT_ID) },
|
|
665
|
+
userNotificationEventType: UserNotificationEventType.AlertCreated,
|
|
666
|
+
failure: failure,
|
|
667
|
+
});
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
name: "alert: Change Alert State",
|
|
672
|
+
family: "alert",
|
|
673
|
+
actionType: MicrosoftTeamsAlertActionType.SubmitChangeAlertState,
|
|
674
|
+
actionValue: ALERT_ID,
|
|
675
|
+
fields: { alertState: ALERT_STATE_ID },
|
|
676
|
+
confirmation: "✅ Alert state changed successfully.",
|
|
677
|
+
failureReply: ACTION_FAILED,
|
|
678
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
679
|
+
const updateOneById: SpyInstance<typeof AlertService.updateOneById> = jest
|
|
680
|
+
.spyOn(AlertService, "updateOneById")
|
|
681
|
+
.mockImplementation((): Promise<number> => {
|
|
682
|
+
return writeOutcome(1, failure);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
return (): void => {
|
|
686
|
+
expect(updateOneById).toHaveBeenCalledTimes(1);
|
|
687
|
+
expect(updateOneById).toHaveBeenCalledWith({
|
|
688
|
+
id: new ObjectID(ALERT_ID),
|
|
689
|
+
data: { currentAlertStateId: new ObjectID(ALERT_STATE_ID) },
|
|
690
|
+
props: MEMBER_PROPS,
|
|
691
|
+
});
|
|
692
|
+
};
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
name: "alert episode: Add Note",
|
|
697
|
+
family: "alert episode",
|
|
698
|
+
actionType: MicrosoftTeamsAlertEpisodeActionType.SubmitAlertEpisodeNote,
|
|
699
|
+
actionValue: ALERT_EPISODE_ID,
|
|
700
|
+
fields: { note: NOTE },
|
|
701
|
+
confirmation: "✅ Note added successfully.",
|
|
702
|
+
failureReply: ACTION_FAILED,
|
|
703
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
704
|
+
const addNote: SpyInstance<
|
|
705
|
+
typeof AlertEpisodeInternalNoteService.addNote
|
|
706
|
+
> = jest
|
|
707
|
+
.spyOn(AlertEpisodeInternalNoteService, "addNote")
|
|
708
|
+
.mockImplementation((): Promise<AlertEpisodeInternalNote> => {
|
|
709
|
+
return writeOutcome(new AlertEpisodeInternalNote(), failure);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
return (): void => {
|
|
713
|
+
expect(addNote).toHaveBeenCalledTimes(1);
|
|
714
|
+
expect(addNote).toHaveBeenCalledWith({
|
|
715
|
+
alertEpisodeId: new ObjectID(ALERT_EPISODE_ID),
|
|
716
|
+
note: NOTE,
|
|
717
|
+
projectId: PROJECT_ID,
|
|
718
|
+
userId: USER_ID,
|
|
719
|
+
});
|
|
720
|
+
};
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
name: "alert episode: Execute On-Call Policy",
|
|
725
|
+
family: "alert episode",
|
|
726
|
+
actionType:
|
|
727
|
+
MicrosoftTeamsAlertEpisodeActionType.SubmitExecuteAlertEpisodeOnCallPolicy,
|
|
728
|
+
actionValue: ALERT_EPISODE_ID,
|
|
729
|
+
fields: { onCallPolicy: ON_CALL_POLICY_ID },
|
|
730
|
+
confirmation: "✅ On-call policy executed successfully.",
|
|
731
|
+
failureReply: ACTION_FAILED,
|
|
732
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
733
|
+
return stubExecutePolicy({
|
|
734
|
+
trigger: { triggeredByAlertEpisodeId: new ObjectID(ALERT_EPISODE_ID) },
|
|
735
|
+
userNotificationEventType:
|
|
736
|
+
UserNotificationEventType.AlertEpisodeCreated,
|
|
737
|
+
failure: failure,
|
|
738
|
+
});
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "alert episode: Change Episode State",
|
|
743
|
+
family: "alert episode",
|
|
744
|
+
actionType:
|
|
745
|
+
MicrosoftTeamsAlertEpisodeActionType.SubmitChangeAlertEpisodeState,
|
|
746
|
+
actionValue: ALERT_EPISODE_ID,
|
|
747
|
+
fields: { alertState: ALERT_STATE_ID },
|
|
748
|
+
confirmation: "✅ Alert episode state changed successfully.",
|
|
749
|
+
failureReply: ACTION_FAILED,
|
|
750
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
751
|
+
const changeEpisodeState: SpyInstance<
|
|
752
|
+
typeof AlertEpisodeService.changeEpisodeState
|
|
753
|
+
> = jest
|
|
754
|
+
.spyOn(AlertEpisodeService, "changeEpisodeState")
|
|
755
|
+
.mockImplementation((): Promise<void> => {
|
|
756
|
+
return writeOutcome(undefined, failure);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
return (): void => {
|
|
760
|
+
expect(changeEpisodeState).toHaveBeenCalledTimes(1);
|
|
761
|
+
expect(changeEpisodeState).toHaveBeenCalledWith({
|
|
762
|
+
projectId: PROJECT_ID,
|
|
763
|
+
episodeId: new ObjectID(ALERT_EPISODE_ID),
|
|
764
|
+
alertStateId: new ObjectID(ALERT_STATE_ID),
|
|
765
|
+
notifyOwners: true,
|
|
766
|
+
rootCause: "State changed via Microsoft Teams.",
|
|
767
|
+
props: { isRoot: true },
|
|
768
|
+
});
|
|
769
|
+
};
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
name: "incident episode: Add Note",
|
|
774
|
+
family: "incident episode",
|
|
775
|
+
actionType:
|
|
776
|
+
MicrosoftTeamsIncidentEpisodeActionType.SubmitIncidentEpisodeNote,
|
|
777
|
+
actionValue: INCIDENT_EPISODE_ID,
|
|
778
|
+
fields: { note: NOTE },
|
|
779
|
+
confirmation: "Note added successfully.",
|
|
780
|
+
failureReply: ACTION_FAILED,
|
|
781
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
782
|
+
const addNote: SpyInstance<
|
|
783
|
+
typeof IncidentEpisodeInternalNoteService.addNote
|
|
784
|
+
> = jest
|
|
785
|
+
.spyOn(IncidentEpisodeInternalNoteService, "addNote")
|
|
786
|
+
.mockImplementation((): Promise<IncidentEpisodeInternalNote> => {
|
|
787
|
+
return writeOutcome(new IncidentEpisodeInternalNote(), failure);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
return (): void => {
|
|
791
|
+
expect(addNote).toHaveBeenCalledTimes(1);
|
|
792
|
+
expect(addNote).toHaveBeenCalledWith({
|
|
793
|
+
incidentEpisodeId: new ObjectID(INCIDENT_EPISODE_ID),
|
|
794
|
+
note: NOTE,
|
|
795
|
+
projectId: PROJECT_ID,
|
|
796
|
+
userId: USER_ID,
|
|
797
|
+
});
|
|
798
|
+
};
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: "incident episode: Execute On-Call Policy",
|
|
803
|
+
family: "incident episode",
|
|
804
|
+
actionType:
|
|
805
|
+
MicrosoftTeamsIncidentEpisodeActionType.SubmitExecuteIncidentEpisodeOnCallPolicy,
|
|
806
|
+
actionValue: INCIDENT_EPISODE_ID,
|
|
807
|
+
fields: { onCallPolicy: ON_CALL_POLICY_ID },
|
|
808
|
+
confirmation: "On-call policy executed successfully.",
|
|
809
|
+
failureReply: ACTION_FAILED,
|
|
810
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
811
|
+
return stubExecutePolicy({
|
|
812
|
+
trigger: {
|
|
813
|
+
triggeredByIncidentEpisodeId: new ObjectID(INCIDENT_EPISODE_ID),
|
|
814
|
+
},
|
|
815
|
+
userNotificationEventType:
|
|
816
|
+
UserNotificationEventType.IncidentEpisodeCreated,
|
|
817
|
+
failure: failure,
|
|
818
|
+
});
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
name: "incident episode: Change Episode State",
|
|
823
|
+
family: "incident episode",
|
|
824
|
+
actionType:
|
|
825
|
+
MicrosoftTeamsIncidentEpisodeActionType.SubmitChangeIncidentEpisodeState,
|
|
826
|
+
actionValue: INCIDENT_EPISODE_ID,
|
|
827
|
+
fields: { incidentState: INCIDENT_STATE_ID },
|
|
828
|
+
confirmation: "Incident episode state changed successfully.",
|
|
829
|
+
failureReply: ACTION_FAILED,
|
|
830
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
831
|
+
const changeEpisodeState: SpyInstance<
|
|
832
|
+
typeof IncidentEpisodeService.changeEpisodeState
|
|
833
|
+
> = jest
|
|
834
|
+
.spyOn(IncidentEpisodeService, "changeEpisodeState")
|
|
835
|
+
.mockImplementation((): Promise<void> => {
|
|
836
|
+
return writeOutcome(undefined, failure);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
return (): void => {
|
|
840
|
+
expect(changeEpisodeState).toHaveBeenCalledTimes(1);
|
|
841
|
+
expect(changeEpisodeState).toHaveBeenCalledWith({
|
|
842
|
+
projectId: PROJECT_ID,
|
|
843
|
+
episodeId: new ObjectID(INCIDENT_EPISODE_ID),
|
|
844
|
+
incidentStateId: new ObjectID(INCIDENT_STATE_ID),
|
|
845
|
+
notifyOwners: true,
|
|
846
|
+
rootCause: "State changed via Microsoft Teams.",
|
|
847
|
+
props: { isRoot: true },
|
|
848
|
+
});
|
|
849
|
+
};
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
name: "scheduled maintenance: Add Note (public)",
|
|
854
|
+
family: "scheduled maintenance",
|
|
855
|
+
actionType:
|
|
856
|
+
MicrosoftTeamsScheduledMaintenanceActionType.SubmitScheduledMaintenanceNote,
|
|
857
|
+
actionValue: "",
|
|
858
|
+
fields: {
|
|
859
|
+
scheduledMaintenanceId: MAINTENANCE_ID,
|
|
860
|
+
note: NOTE,
|
|
861
|
+
isPublic: true,
|
|
862
|
+
},
|
|
863
|
+
confirmation: "Note added successfully",
|
|
864
|
+
failureReply: MAINTENANCE_ACTION_FAILED,
|
|
865
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
866
|
+
stubExistingMaintenance();
|
|
867
|
+
const addNote: SpyInstance<
|
|
868
|
+
typeof ScheduledMaintenancePublicNoteService.addNote
|
|
869
|
+
> = jest
|
|
870
|
+
.spyOn(ScheduledMaintenancePublicNoteService, "addNote")
|
|
871
|
+
.mockImplementation((): Promise<ScheduledMaintenancePublicNote> => {
|
|
872
|
+
return writeOutcome(new ScheduledMaintenancePublicNote(), failure);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
return (): void => {
|
|
876
|
+
const added: JSONObject = firstArgument(addNote);
|
|
877
|
+
// The id arrives as the card sent it.
|
|
878
|
+
expect(String(added["scheduledMaintenanceId"])).toBe(MAINTENANCE_ID);
|
|
879
|
+
expect(added["note"]).toBe(NOTE);
|
|
880
|
+
expect(added["projectId"]).toEqual(PROJECT_ID);
|
|
881
|
+
expect(added["userId"]).toEqual(USER_ID);
|
|
882
|
+
};
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
name: "scheduled maintenance: Add Note (private)",
|
|
887
|
+
family: "scheduled maintenance",
|
|
888
|
+
actionType:
|
|
889
|
+
MicrosoftTeamsScheduledMaintenanceActionType.SubmitScheduledMaintenanceNote,
|
|
890
|
+
actionValue: "",
|
|
891
|
+
fields: {
|
|
892
|
+
scheduledMaintenanceId: MAINTENANCE_ID,
|
|
893
|
+
note: NOTE,
|
|
894
|
+
isPublic: false,
|
|
895
|
+
},
|
|
896
|
+
confirmation: "Note added successfully",
|
|
897
|
+
failureReply: MAINTENANCE_ACTION_FAILED,
|
|
898
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
899
|
+
stubExistingMaintenance();
|
|
900
|
+
const addNote: SpyInstance<
|
|
901
|
+
typeof ScheduledMaintenanceInternalNoteService.addNote
|
|
902
|
+
> = jest
|
|
903
|
+
.spyOn(ScheduledMaintenanceInternalNoteService, "addNote")
|
|
904
|
+
.mockImplementation((): Promise<ScheduledMaintenanceInternalNote> => {
|
|
905
|
+
return writeOutcome(new ScheduledMaintenanceInternalNote(), failure);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
return (): void => {
|
|
909
|
+
const added: JSONObject = firstArgument(addNote);
|
|
910
|
+
expect(String(added["scheduledMaintenanceId"])).toBe(MAINTENANCE_ID);
|
|
911
|
+
expect(added["note"]).toBe(NOTE);
|
|
912
|
+
expect(added["projectId"]).toEqual(PROJECT_ID);
|
|
913
|
+
expect(added["userId"]).toEqual(USER_ID);
|
|
914
|
+
};
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
name: "scheduled maintenance: Change State",
|
|
919
|
+
family: "scheduled maintenance",
|
|
920
|
+
actionType:
|
|
921
|
+
MicrosoftTeamsScheduledMaintenanceActionType.SubmitChangeScheduledMaintenanceState,
|
|
922
|
+
actionValue: "",
|
|
923
|
+
fields: {
|
|
924
|
+
scheduledMaintenanceId: MAINTENANCE_ID,
|
|
925
|
+
stateId: MAINTENANCE_STATE_ID,
|
|
926
|
+
},
|
|
927
|
+
confirmation: "ScheduledMaintenance state changed successfully",
|
|
928
|
+
failureReply: MAINTENANCE_ACTION_FAILED,
|
|
929
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
930
|
+
stubExistingMaintenance();
|
|
931
|
+
const updateOneById: SpyInstance<
|
|
932
|
+
typeof ScheduledMaintenanceService.updateOneById
|
|
933
|
+
> = jest
|
|
934
|
+
.spyOn(ScheduledMaintenanceService, "updateOneById")
|
|
935
|
+
.mockImplementation((): Promise<number> => {
|
|
936
|
+
return writeOutcome(1, failure);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
return (): void => {
|
|
940
|
+
const update: JSONObject = firstArgument(updateOneById);
|
|
941
|
+
expect(String(update["id"])).toBe(MAINTENANCE_ID);
|
|
942
|
+
expect(
|
|
943
|
+
String(
|
|
944
|
+
(update["data"] as JSONObject)[
|
|
945
|
+
"currentScheduledMaintenanceStateId"
|
|
946
|
+
],
|
|
947
|
+
),
|
|
948
|
+
).toBe(MAINTENANCE_STATE_ID);
|
|
949
|
+
};
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
name: "scheduled maintenance: Create New Scheduled Maintenance",
|
|
954
|
+
family: "scheduled maintenance",
|
|
955
|
+
actionType:
|
|
956
|
+
MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance,
|
|
957
|
+
actionValue: "",
|
|
958
|
+
fields: {
|
|
959
|
+
scheduledMaintenanceTitle: "Database upgrade",
|
|
960
|
+
scheduledMaintenanceDescription: "Upgrading the primary to 16.4.",
|
|
961
|
+
startDate: "2099-06-01",
|
|
962
|
+
startTime: "10:00",
|
|
963
|
+
endDate: "2099-06-01",
|
|
964
|
+
endTime: "11:00",
|
|
965
|
+
},
|
|
966
|
+
// The submit names no time zone, so the times are read and shown in UTC.
|
|
967
|
+
confirmation: `✅ Scheduled maintenance created successfully!\n\n**Starts:** Jun 1, 2099, 10:00 (UTC)\n\n**Ends:** Jun 1, 2099, 11:00 (UTC)\n\nView scheduled maintenance: ${MAINTENANCE_LINK}`,
|
|
968
|
+
failureReply: MAINTENANCE_CREATE_FAILED,
|
|
969
|
+
stubWrite: (failure?: Error): WriteCheck => {
|
|
970
|
+
const created: ScheduledMaintenance = new ScheduledMaintenance();
|
|
971
|
+
created.id = CREATED_ID;
|
|
972
|
+
created.projectId = PROJECT_ID;
|
|
973
|
+
|
|
974
|
+
jest
|
|
975
|
+
.spyOn(
|
|
976
|
+
WorkspaceProjectReferenceValidator,
|
|
977
|
+
"validateReferencesBelongToProject",
|
|
978
|
+
)
|
|
979
|
+
.mockResolvedValue();
|
|
980
|
+
jest
|
|
981
|
+
.spyOn(
|
|
982
|
+
ScheduledMaintenanceService,
|
|
983
|
+
"getScheduledMaintenanceLinkInDashboard",
|
|
984
|
+
)
|
|
985
|
+
.mockResolvedValue(URL.fromString(MAINTENANCE_LINK));
|
|
986
|
+
const create: SpyInstance<typeof ScheduledMaintenanceService.create> =
|
|
987
|
+
jest
|
|
988
|
+
.spyOn(ScheduledMaintenanceService, "create")
|
|
989
|
+
.mockImplementation((): Promise<ScheduledMaintenance> => {
|
|
990
|
+
return writeOutcome(created, failure);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
return (): void => {
|
|
994
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
995
|
+
const maintenance: ScheduledMaintenance = create.mock.calls[0]![0].data;
|
|
996
|
+
expect(maintenance.title).toBe("Database upgrade");
|
|
997
|
+
expect(maintenance.projectId).toEqual(PROJECT_ID);
|
|
998
|
+
expect(maintenance.startsAt).toEqual(
|
|
999
|
+
new Date("2099-06-01T10:00:00.000Z"),
|
|
1000
|
+
);
|
|
1001
|
+
};
|
|
1002
|
+
},
|
|
1003
|
+
},
|
|
1004
|
+
];
|
|
1005
|
+
|
|
1006
|
+
const CREATE_FORMS: ReadonlyArray<SubmittedForm> = SUBMITTED_FORMS.filter(
|
|
1007
|
+
(form: SubmittedForm) => {
|
|
1008
|
+
return CREATE_SUBMITS.includes(form.actionType);
|
|
1009
|
+
},
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
// What only the two create submits have.
|
|
1013
|
+
interface CreateSubmitDetails {
|
|
1014
|
+
// The failure reply when no link to the dashboard can be built.
|
|
1015
|
+
failureReplyWithoutLink: string;
|
|
1016
|
+
// What the create logs when it fails, before the reason.
|
|
1017
|
+
failureLogSummary: string;
|
|
1018
|
+
// The reply when a value the submit picked is not in the project.
|
|
1019
|
+
unavailableReferenceReply: string;
|
|
1020
|
+
// What the project check calls the record: "This <subject> references...".
|
|
1021
|
+
subject: string;
|
|
1022
|
+
// The submit field the picked monitors arrive in.
|
|
1023
|
+
monitorsField: string;
|
|
1024
|
+
// Stubs the create, which must not run: it rejects if it does.
|
|
1025
|
+
stubCreateThatMustNotRun: () => { mock: { calls: Array<unknown> } };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const CREATE_SUBMIT_DETAILS: Record<string, CreateSubmitDetails> = {
|
|
1029
|
+
[MicrosoftTeamsIncidentActionType.SubmitNewIncident]: {
|
|
1030
|
+
failureReplyWithoutLink: INCIDENT_CREATE_FAILED_WITHOUT_LINK,
|
|
1031
|
+
failureLogSummary: "Could not create an incident from Microsoft Teams",
|
|
1032
|
+
unavailableReferenceReply: `❌ Could not create the incident: ${UNAVAILABLE_REFERENCE}`,
|
|
1033
|
+
subject: "incident",
|
|
1034
|
+
monitorsField: "incidentMonitors",
|
|
1035
|
+
stubCreateThatMustNotRun: (): { mock: { calls: Array<unknown> } } => {
|
|
1036
|
+
return jest
|
|
1037
|
+
.spyOn(IncidentService, "create")
|
|
1038
|
+
.mockRejectedValue(new Error("The incident must not be created."));
|
|
1039
|
+
},
|
|
1040
|
+
},
|
|
1041
|
+
[MicrosoftTeamsScheduledMaintenanceActionType.SubmitNewScheduledMaintenance]:
|
|
1042
|
+
{
|
|
1043
|
+
failureReplyWithoutLink: MAINTENANCE_CREATE_FAILED_WITHOUT_LINK,
|
|
1044
|
+
failureLogSummary:
|
|
1045
|
+
"Could not create a scheduled maintenance event from Microsoft Teams",
|
|
1046
|
+
unavailableReferenceReply: `❌ Could not create the scheduled maintenance event: ${UNAVAILABLE_REFERENCE}`,
|
|
1047
|
+
subject: "scheduled maintenance event",
|
|
1048
|
+
monitorsField: "scheduledMaintenanceMonitors",
|
|
1049
|
+
stubCreateThatMustNotRun: (): { mock: { calls: Array<unknown> } } => {
|
|
1050
|
+
return jest
|
|
1051
|
+
.spyOn(ScheduledMaintenanceService, "create")
|
|
1052
|
+
.mockRejectedValue(new Error("The event must not be created."));
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
// The details of a form in CREATE_FORMS.
|
|
1058
|
+
function createDetailsOf(form: SubmittedForm): CreateSubmitDetails {
|
|
1059
|
+
const details: CreateSubmitDetails | undefined =
|
|
1060
|
+
CREATE_SUBMIT_DETAILS[form.actionType];
|
|
1061
|
+
|
|
1062
|
+
if (!details) {
|
|
1063
|
+
throw new Error(`"${form.name}" is not a create form.`);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return details;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// The monitor the project check finds for FOREIGN_MONITOR_ID.
|
|
1070
|
+
function foreignMonitor(): Monitor {
|
|
1071
|
+
const monitor: Monitor = new Monitor();
|
|
1072
|
+
monitor.id = new ObjectID(FOREIGN_MONITOR_ID);
|
|
1073
|
+
monitor.projectId = OTHER_PROJECT_ID;
|
|
1074
|
+
monitor.name = FOREIGN_MONITOR_NAME;
|
|
1075
|
+
return monitor;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/*
|
|
1079
|
+
* Every logger.error call for a submit whose write failed with `failure`,
|
|
1080
|
+
* through handleBotInvokeActivity. A create, and handleBotInvokeActivity for
|
|
1081
|
+
* the incident, alert and episode submits, log what failed and why, then the
|
|
1082
|
+
* error itself, which keeps its stack. The scheduled maintenance handler
|
|
1083
|
+
* catches the failure itself and logs one line.
|
|
1084
|
+
*/
|
|
1085
|
+
function failureLogCalls(
|
|
1086
|
+
form: SubmittedForm,
|
|
1087
|
+
failure: Error,
|
|
1088
|
+
): Array<Array<unknown>> {
|
|
1089
|
+
const reason: string = `Error: ${failure.message}`;
|
|
1090
|
+
const createDetails: CreateSubmitDetails | undefined =
|
|
1091
|
+
CREATE_SUBMIT_DETAILS[form.actionType];
|
|
1092
|
+
|
|
1093
|
+
if (createDetails) {
|
|
1094
|
+
const attributes: LogAttributes = { projectId: PROJECT_ID.toString() };
|
|
1095
|
+
|
|
1096
|
+
return [
|
|
1097
|
+
[`${createDetails.failureLogSummary}: ${reason}`, attributes],
|
|
1098
|
+
[failure, attributes],
|
|
1099
|
+
];
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (form.family === "scheduled maintenance") {
|
|
1103
|
+
return [
|
|
1104
|
+
[
|
|
1105
|
+
`Error handling scheduled maintenance action: ${reason}`,
|
|
1106
|
+
{ actionType: form.actionType },
|
|
1107
|
+
],
|
|
1108
|
+
];
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const attributes: LogAttributes = {
|
|
1112
|
+
actionType: form.actionType,
|
|
1113
|
+
projectId: PROJECT_ID.toString(),
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
return [
|
|
1117
|
+
[`Error handling bot invoke activity: ${reason}`, attributes],
|
|
1118
|
+
[failure, attributes],
|
|
1119
|
+
];
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Which file of the Teams bot each family's handler lives in.
|
|
1123
|
+
const FAMILY_SOURCE_FILES: Record<HandlerFamily, string> = {
|
|
1124
|
+
incident: "Actions/Incident.ts",
|
|
1125
|
+
alert: "Actions/Alert.ts",
|
|
1126
|
+
"alert episode": "Actions/AlertEpisode.ts",
|
|
1127
|
+
"incident episode": "Actions/IncidentEpisode.ts",
|
|
1128
|
+
"scheduled maintenance": "Actions/ScheduledMaintenance.ts",
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
// The submit's value: the card's action data plus what the user filled in.
|
|
1132
|
+
function submitValue(form: SubmittedForm): JSONObject {
|
|
1133
|
+
return {
|
|
1134
|
+
action: form.actionType,
|
|
1135
|
+
actionValue: form.actionValue,
|
|
1136
|
+
...form.fields,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// The message activity Teams sends when a form's submit button is pressed.
|
|
1141
|
+
function submitActivity(
|
|
1142
|
+
form: SubmittedForm,
|
|
1143
|
+
options?: { withoutForm?: boolean | undefined },
|
|
1144
|
+
): JSONObject {
|
|
1145
|
+
const activity: JSONObject = {
|
|
1146
|
+
type: "message",
|
|
1147
|
+
id: SUBMIT_ACTIVITY_ID,
|
|
1148
|
+
from: { id: "29:teams-user", aadObjectId: TEAMS_USER_ID, name: "Alex" },
|
|
1149
|
+
recipient: { id: BOT_ID },
|
|
1150
|
+
conversation: { conversationType: "personal", id: "a:personal-chat" },
|
|
1151
|
+
channelData: { tenant: { id: TENANT_ID } },
|
|
1152
|
+
value: submitValue(form),
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
if (!options?.withoutForm) {
|
|
1156
|
+
activity["replyToId"] = FORM_ACTIVITY_ID;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
return activity;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// The arguments handleBotInvokeActivity passes every bot action handler.
|
|
1163
|
+
function botActionArguments(
|
|
1164
|
+
form: SubmittedForm,
|
|
1165
|
+
turnContext: TurnContext,
|
|
1166
|
+
): {
|
|
1167
|
+
actionType: string;
|
|
1168
|
+
actionValue: string;
|
|
1169
|
+
value: JSONObject;
|
|
1170
|
+
projectId: ObjectID;
|
|
1171
|
+
oneUptimeUserId: ObjectID;
|
|
1172
|
+
databaseProps: DatabaseCommonInteractionProps;
|
|
1173
|
+
turnContext: TurnContext;
|
|
1174
|
+
} {
|
|
1175
|
+
return {
|
|
1176
|
+
actionType: form.actionType,
|
|
1177
|
+
actionValue: form.actionValue,
|
|
1178
|
+
value: submitValue(form),
|
|
1179
|
+
projectId: PROJECT_ID,
|
|
1180
|
+
oneUptimeUserId: USER_ID,
|
|
1181
|
+
databaseProps: MEMBER_PROPS,
|
|
1182
|
+
turnContext: turnContext,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
type SubmitHandler = (
|
|
1187
|
+
form: SubmittedForm,
|
|
1188
|
+
turnContext: TurnContext,
|
|
1189
|
+
) => Promise<void>;
|
|
1190
|
+
|
|
1191
|
+
// Each family's real bot handler, called as handleBotInvokeActivity calls it.
|
|
1192
|
+
const HANDLERS: Record<HandlerFamily, SubmitHandler> = {
|
|
1193
|
+
incident: (form: SubmittedForm, turnContext: TurnContext): Promise<void> => {
|
|
1194
|
+
return MicrosoftTeamsIncidentActions.handleBotIncidentAction(
|
|
1195
|
+
botActionArguments(form, turnContext),
|
|
1196
|
+
);
|
|
1197
|
+
},
|
|
1198
|
+
alert: (form: SubmittedForm, turnContext: TurnContext): Promise<void> => {
|
|
1199
|
+
return MicrosoftTeamsAlertActions.handleBotAlertAction(
|
|
1200
|
+
botActionArguments(form, turnContext),
|
|
1201
|
+
);
|
|
1202
|
+
},
|
|
1203
|
+
"alert episode": (
|
|
1204
|
+
form: SubmittedForm,
|
|
1205
|
+
turnContext: TurnContext,
|
|
1206
|
+
): Promise<void> => {
|
|
1207
|
+
return MicrosoftTeamsAlertEpisodeActions.handleBotAlertEpisodeAction(
|
|
1208
|
+
botActionArguments(form, turnContext),
|
|
1209
|
+
);
|
|
1210
|
+
},
|
|
1211
|
+
"incident episode": (
|
|
1212
|
+
form: SubmittedForm,
|
|
1213
|
+
turnContext: TurnContext,
|
|
1214
|
+
): Promise<void> => {
|
|
1215
|
+
return MicrosoftTeamsIncidentEpisodeActions.handleBotIncidentEpisodeAction(
|
|
1216
|
+
botActionArguments(form, turnContext),
|
|
1217
|
+
);
|
|
1218
|
+
},
|
|
1219
|
+
"scheduled maintenance": (
|
|
1220
|
+
form: SubmittedForm,
|
|
1221
|
+
turnContext: TurnContext,
|
|
1222
|
+
): Promise<void> => {
|
|
1223
|
+
return MicrosoftTeamsScheduledMaintenanceActions.handleBotScheduledMaintenanceAction(
|
|
1224
|
+
form.actionType as MicrosoftTeamsScheduledMaintenanceActionType,
|
|
1225
|
+
turnContext,
|
|
1226
|
+
submitValue(form),
|
|
1227
|
+
MAINTENANCE_REQUEST,
|
|
1228
|
+
MEMBER_PROPS,
|
|
1229
|
+
);
|
|
1230
|
+
},
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
/*
|
|
1234
|
+
* What handleBotInvokeActivity looks up before it calls a handler: the
|
|
1235
|
+
* project the tenant installed the app for, the OneUptime user linked to
|
|
1236
|
+
* the sender, and that user's permissions as a member of the project.
|
|
1237
|
+
*/
|
|
1238
|
+
function stubInvokeLookups(): void {
|
|
1239
|
+
const projectAuth: WorkspaceProjectAuthToken =
|
|
1240
|
+
new WorkspaceProjectAuthToken();
|
|
1241
|
+
projectAuth.projectId = PROJECT_ID;
|
|
1242
|
+
|
|
1243
|
+
jest.spyOn(MicrosoftTeamsUtil, "resolveProjectByTenantId").mockResolvedValue({
|
|
1244
|
+
projectAuth: projectAuth,
|
|
1245
|
+
isAmbiguous: false,
|
|
1246
|
+
candidateProjectIds: [PROJECT_ID],
|
|
1247
|
+
});
|
|
1248
|
+
jest
|
|
1249
|
+
.spyOn(MicrosoftTeamsAuthAction, "getOneUptimeUserIdFromTeamsUserId")
|
|
1250
|
+
.mockResolvedValue(USER_ID);
|
|
1251
|
+
jest
|
|
1252
|
+
.spyOn(WorkspaceActionAuthorization, "getProjectMemberProps")
|
|
1253
|
+
.mockResolvedValue(MEMBER_PROPS);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
let debugLog: SpyInstance<typeof logger.debug>;
|
|
1257
|
+
let errorLog: SpyInstance<typeof logger.error>;
|
|
1258
|
+
|
|
1259
|
+
// Each refused removal deleteBestEffort logged, as the debug line says it.
|
|
1260
|
+
function refusedRemovalsLogged(): Array<string> {
|
|
1261
|
+
return debugLog.mock.calls
|
|
1262
|
+
.map((call: Parameters<typeof logger.debug>) => {
|
|
1263
|
+
return call[0];
|
|
1264
|
+
})
|
|
1265
|
+
.filter((message: unknown): message is string => {
|
|
1266
|
+
return (
|
|
1267
|
+
typeof message === "string" &&
|
|
1268
|
+
message.startsWith(REMOVAL_REFUSED_LOG_PREFIX)
|
|
1269
|
+
);
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/*
|
|
1274
|
+
* Every logger.error call, with exactly the arguments it was given. Copied
|
|
1275
|
+
* into arrays made here: jest records calls in arrays of its own realm,
|
|
1276
|
+
* which toStrictEqual would tell apart from the literals they are compared
|
|
1277
|
+
* with.
|
|
1278
|
+
*/
|
|
1279
|
+
function errorLogCalls(): Array<Array<unknown>> {
|
|
1280
|
+
return Array.from(
|
|
1281
|
+
errorLog.mock.calls,
|
|
1282
|
+
(call: Parameters<typeof logger.error>): Array<unknown> => {
|
|
1283
|
+
return [...call];
|
|
1284
|
+
},
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
beforeEach((): void => {
|
|
1289
|
+
debugLog = jest.spyOn(logger, "debug").mockImplementation((): void => {});
|
|
1290
|
+
errorLog = jest.spyOn(logger, "error").mockImplementation((): void => {});
|
|
1291
|
+
|
|
1292
|
+
// Permissions are pinned in the action authorization tests.
|
|
1293
|
+
jest
|
|
1294
|
+
.spyOn(WorkspaceActionAuthorization, "assertCanCreate")
|
|
1295
|
+
.mockResolvedValue();
|
|
1296
|
+
|
|
1297
|
+
// Where a failed create sends the user instead.
|
|
1298
|
+
jest
|
|
1299
|
+
.spyOn(DatabaseConfig, "getDashboardUrl")
|
|
1300
|
+
.mockResolvedValue(URL.fromString(DASHBOARD_URL));
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
afterEach((): void => {
|
|
1304
|
+
jest.restoreAllMocks();
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
/*
|
|
1308
|
+
* The basics of deleteBestEffort (no id, a synchronous throw) are pinned in
|
|
1309
|
+
* MicrosoftTeamsReplies.test.ts. Here: the ways Teams actually refuses to
|
|
1310
|
+
* remove a submitted form, each swallowed and logged at debug.
|
|
1311
|
+
*/
|
|
1312
|
+
describe("MicrosoftTeamsReplies.deleteBestEffort: the ways Teams refuses to remove a form", (): void => {
|
|
1313
|
+
interface RemovalFailure {
|
|
1314
|
+
name: string;
|
|
1315
|
+
failure: () => unknown;
|
|
1316
|
+
// How the debug log line names the failure.
|
|
1317
|
+
described: string;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const REMOVAL_FAILURES: ReadonlyArray<RemovalFailure> = [
|
|
1321
|
+
{
|
|
1322
|
+
name: "404 ActivityNotFoundInConversation (the form is gone already)",
|
|
1323
|
+
failure: formAlreadyGone,
|
|
1324
|
+
described:
|
|
1325
|
+
"RestError 404 ActivityNotFoundInConversation: The activity was not found in the conversation.",
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
name: "403 BotNotInConversationRoster (the bot was removed from the chat)",
|
|
1329
|
+
failure: (): Error => {
|
|
1330
|
+
return teamsRefusal({
|
|
1331
|
+
statusCode: 403,
|
|
1332
|
+
code: "BotNotInConversationRoster",
|
|
1333
|
+
message: "The bot is not part of the conversation roster.",
|
|
1334
|
+
});
|
|
1335
|
+
},
|
|
1336
|
+
described:
|
|
1337
|
+
"RestError 403 BotNotInConversationRoster: The bot is not part of the conversation roster.",
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
name: "a dropped connection",
|
|
1341
|
+
failure: (): Error => {
|
|
1342
|
+
return Object.assign(new Error("socket hang up"), {
|
|
1343
|
+
code: "ECONNRESET",
|
|
1344
|
+
});
|
|
1345
|
+
},
|
|
1346
|
+
described: "Error ECONNRESET: socket hang up",
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
name: "a rejection that is not an Error",
|
|
1350
|
+
failure: (): string => {
|
|
1351
|
+
return "request timed out";
|
|
1352
|
+
},
|
|
1353
|
+
described: "request timed out",
|
|
1354
|
+
},
|
|
1355
|
+
];
|
|
1356
|
+
|
|
1357
|
+
test.each(REMOVAL_FAILURES)(
|
|
1358
|
+
"$name: resolves, answers nothing, and logs it at debug - not as an error",
|
|
1359
|
+
async (removal: RemovalFailure): Promise<void> => {
|
|
1360
|
+
const turn: FakeTurn = createFakeTurn({
|
|
1361
|
+
activity: {},
|
|
1362
|
+
deleteError: removal.failure(),
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
await expect(
|
|
1366
|
+
MicrosoftTeamsReplies.deleteBestEffort(
|
|
1367
|
+
turn.turnContext,
|
|
1368
|
+
FORM_ACTIVITY_ID,
|
|
1369
|
+
),
|
|
1370
|
+
).resolves.toBeUndefined();
|
|
1371
|
+
|
|
1372
|
+
// Attempted once, not retried, and no reply about it.
|
|
1373
|
+
expect(turn.events).toEqual([
|
|
1374
|
+
{ kind: "delete", activityId: FORM_ACTIVITY_ID },
|
|
1375
|
+
]);
|
|
1376
|
+
expect(refusedRemovalsLogged()).toEqual([
|
|
1377
|
+
`${REMOVAL_REFUSED_LOG_PREFIX}${removal.described}`,
|
|
1378
|
+
]);
|
|
1379
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
1380
|
+
},
|
|
1381
|
+
);
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
describe("a submitted form that Teams refuses to remove (404 ActivityNotFoundInConversation)", (): void => {
|
|
1385
|
+
test.each(SUBMITTED_FORMS)(
|
|
1386
|
+
"$name: the confirmation stands alone, and nothing is thrown",
|
|
1387
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1388
|
+
const writeHappened: WriteCheck = form.stubWrite();
|
|
1389
|
+
const turn: FakeTurn = createFakeTurn({
|
|
1390
|
+
activity: submitActivity(form),
|
|
1391
|
+
deleteError: formAlreadyGone(),
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
// master: rejected, or (maintenance) answered "An error occurred...".
|
|
1395
|
+
await expect(
|
|
1396
|
+
HANDLERS[form.family](form, turn.turnContext),
|
|
1397
|
+
).resolves.toBeUndefined();
|
|
1398
|
+
|
|
1399
|
+
writeHappened();
|
|
1400
|
+
|
|
1401
|
+
// Confirmed first; then the removal was attempted, once, for the form.
|
|
1402
|
+
expect(turn.events).toEqual([
|
|
1403
|
+
{ kind: "reply", text: form.confirmation },
|
|
1404
|
+
{ kind: "delete", activityId: FORM_ACTIVITY_ID },
|
|
1405
|
+
]);
|
|
1406
|
+
expectNoFailureReply(turn);
|
|
1407
|
+
|
|
1408
|
+
// A form Teams would not remove is not a server error.
|
|
1409
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
1410
|
+
expect(refusedRemovalsLogged()).toEqual([
|
|
1411
|
+
`${REMOVAL_REFUSED_LOG_PREFIX}RestError 404 ActivityNotFoundInConversation: The activity was not found in the conversation.`,
|
|
1412
|
+
]);
|
|
1413
|
+
},
|
|
1414
|
+
);
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
describe("a submitted form that Teams removes", (): void => {
|
|
1418
|
+
test.each(SUBMITTED_FORMS)(
|
|
1419
|
+
"$name: confirmed, then the form is removed",
|
|
1420
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1421
|
+
const writeHappened: WriteCheck = form.stubWrite();
|
|
1422
|
+
const turn: FakeTurn = createFakeTurn({
|
|
1423
|
+
activity: submitActivity(form),
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
await expect(
|
|
1427
|
+
HANDLERS[form.family](form, turn.turnContext),
|
|
1428
|
+
).resolves.toBeUndefined();
|
|
1429
|
+
|
|
1430
|
+
writeHappened();
|
|
1431
|
+
expect(turn.events).toEqual([
|
|
1432
|
+
{ kind: "reply", text: form.confirmation },
|
|
1433
|
+
{ kind: "delete", activityId: FORM_ACTIVITY_ID },
|
|
1434
|
+
]);
|
|
1435
|
+
expect(refusedRemovalsLogged()).toEqual([]);
|
|
1436
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
1437
|
+
},
|
|
1438
|
+
);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
describe("a handler returns only once Teams has answered the removal", (): void => {
|
|
1442
|
+
/*
|
|
1443
|
+
* botbuilder revokes the TurnContext when the turn's handler returns
|
|
1444
|
+
* (BotAdapter.runMiddleware, in a finally). A removal the handler did not
|
|
1445
|
+
* wait for would still be in flight then and fail against the revoked
|
|
1446
|
+
* context: the form would stay on screen, ready to be submitted twice.
|
|
1447
|
+
*/
|
|
1448
|
+
test.each(SUBMITTED_FORMS)(
|
|
1449
|
+
"$name: still waiting while Teams has not answered; done once it has",
|
|
1450
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1451
|
+
form.stubWrite();
|
|
1452
|
+
const removal: DelayedRemoval = delayedRemoval();
|
|
1453
|
+
const turn: FakeTurn = createFakeTurn({
|
|
1454
|
+
activity: submitActivity(form),
|
|
1455
|
+
removalAnswered: removal.answered,
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
const handled: Promise<void> = HANDLERS[form.family](
|
|
1459
|
+
form,
|
|
1460
|
+
turn.turnContext,
|
|
1461
|
+
);
|
|
1462
|
+
|
|
1463
|
+
const settledFirst: string = await Promise.race([
|
|
1464
|
+
handled.then((): string => {
|
|
1465
|
+
return "the handler";
|
|
1466
|
+
}),
|
|
1467
|
+
afterQueuedCallbacks().then((): string => {
|
|
1468
|
+
return "the wait";
|
|
1469
|
+
}),
|
|
1470
|
+
]);
|
|
1471
|
+
|
|
1472
|
+
// The removal was asked for, and the handler is waiting for the answer.
|
|
1473
|
+
expect(settledFirst).toBe("the wait");
|
|
1474
|
+
expect(turn.events).toEqual([
|
|
1475
|
+
{ kind: "reply", text: form.confirmation },
|
|
1476
|
+
{ kind: "delete", activityId: FORM_ACTIVITY_ID },
|
|
1477
|
+
]);
|
|
1478
|
+
|
|
1479
|
+
removal.answer();
|
|
1480
|
+
|
|
1481
|
+
await expect(handled).resolves.toBeUndefined();
|
|
1482
|
+
expect(turn.events).toHaveLength(2);
|
|
1483
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
1484
|
+
},
|
|
1485
|
+
);
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
describe("a submit that does not say which form it came from (no replyToId)", (): void => {
|
|
1489
|
+
test.each(SUBMITTED_FORMS)(
|
|
1490
|
+
"$name: confirmed, and Teams is not asked to remove anything",
|
|
1491
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1492
|
+
const writeHappened: WriteCheck = form.stubWrite();
|
|
1493
|
+
const turn: FakeTurn = createFakeTurn({
|
|
1494
|
+
activity: submitActivity(form, { withoutForm: true }),
|
|
1495
|
+
deleteError: formAlreadyGone(),
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
await expect(
|
|
1499
|
+
HANDLERS[form.family](form, turn.turnContext),
|
|
1500
|
+
).resolves.toBeUndefined();
|
|
1501
|
+
|
|
1502
|
+
writeHappened();
|
|
1503
|
+
expect(turn.events).toEqual([{ kind: "reply", text: form.confirmation }]);
|
|
1504
|
+
expect(refusedRemovalsLogged()).toEqual([]);
|
|
1505
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
1506
|
+
},
|
|
1507
|
+
);
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
describe("a submit whose action fails keeps its form, so it can be submitted again", (): void => {
|
|
1511
|
+
beforeEach((): void => {
|
|
1512
|
+
stubInvokeLookups();
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
test.each(SUBMITTED_FORMS)(
|
|
1516
|
+
"$name: one reply that says it failed; nothing is confirmed, nothing removed",
|
|
1517
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1518
|
+
const failure: Error = databaseUnreachable();
|
|
1519
|
+
const writeHappened: WriteCheck = form.stubWrite(failure);
|
|
1520
|
+
const activity: JSONObject = submitActivity(form);
|
|
1521
|
+
const turn: FakeTurn = createFakeTurn({ activity: activity });
|
|
1522
|
+
|
|
1523
|
+
await expect(
|
|
1524
|
+
MicrosoftTeamsUtil.handleBotInvokeActivity({
|
|
1525
|
+
activity: activity,
|
|
1526
|
+
turnContext: turn.turnContext,
|
|
1527
|
+
}),
|
|
1528
|
+
).resolves.toBeUndefined();
|
|
1529
|
+
|
|
1530
|
+
// The write was tried once, as asked, and the user is told it failed.
|
|
1531
|
+
writeHappened();
|
|
1532
|
+
expect(turn.events).toEqual([{ kind: "reply", text: form.failureReply }]);
|
|
1533
|
+
|
|
1534
|
+
// Logged once, with the error itself: its stack says where it failed.
|
|
1535
|
+
expect(errorLogCalls()).toStrictEqual(failureLogCalls(form, failure));
|
|
1536
|
+
},
|
|
1537
|
+
);
|
|
1538
|
+
|
|
1539
|
+
test.each(CREATE_FORMS)(
|
|
1540
|
+
"$name, with no dashboard URL known: the same one reply, without the link",
|
|
1541
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1542
|
+
jest
|
|
1543
|
+
.spyOn(DatabaseConfig, "getDashboardUrl")
|
|
1544
|
+
.mockRejectedValue(new Error("No host is configured."));
|
|
1545
|
+
const writeHappened: WriteCheck = form.stubWrite(databaseUnreachable());
|
|
1546
|
+
const activity: JSONObject = submitActivity(form);
|
|
1547
|
+
const turn: FakeTurn = createFakeTurn({ activity: activity });
|
|
1548
|
+
|
|
1549
|
+
await expect(
|
|
1550
|
+
MicrosoftTeamsUtil.handleBotInvokeActivity({
|
|
1551
|
+
activity: activity,
|
|
1552
|
+
turnContext: turn.turnContext,
|
|
1553
|
+
}),
|
|
1554
|
+
).resolves.toBeUndefined();
|
|
1555
|
+
|
|
1556
|
+
writeHappened();
|
|
1557
|
+
expect(turn.events).toEqual([
|
|
1558
|
+
{ kind: "reply", text: createDetailsOf(form).failureReplyWithoutLink },
|
|
1559
|
+
]);
|
|
1560
|
+
},
|
|
1561
|
+
);
|
|
1562
|
+
|
|
1563
|
+
/*
|
|
1564
|
+
* A submit carries ids, and nothing binds them to the form that was sent:
|
|
1565
|
+
* a record deleted since, or another project's id in a tampered submit.
|
|
1566
|
+
* The project check refuses it before anything is created, with a message
|
|
1567
|
+
* that names the record - fine for the log, not for a chat that may be
|
|
1568
|
+
* another project's. "Please pick it again" needs the form, so it stays.
|
|
1569
|
+
*/
|
|
1570
|
+
test.each(CREATE_FORMS)(
|
|
1571
|
+
"$name with another project's monitor picked: the fixed reason, which names no record, and the form stays to pick again",
|
|
1572
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1573
|
+
const details: CreateSubmitDetails = createDetailsOf(form);
|
|
1574
|
+
const findMonitors: SpyInstance<typeof MonitorService.findBy> = jest
|
|
1575
|
+
.spyOn(MonitorService, "findBy")
|
|
1576
|
+
.mockResolvedValue([foreignMonitor()]);
|
|
1577
|
+
const create: { mock: { calls: Array<unknown> } } =
|
|
1578
|
+
details.stubCreateThatMustNotRun();
|
|
1579
|
+
const tampered: SubmittedForm = {
|
|
1580
|
+
...form,
|
|
1581
|
+
fields: { ...form.fields, [details.monitorsField]: FOREIGN_MONITOR_ID },
|
|
1582
|
+
};
|
|
1583
|
+
const activity: JSONObject = submitActivity(tampered);
|
|
1584
|
+
const turn: FakeTurn = createFakeTurn({ activity: activity });
|
|
1585
|
+
|
|
1586
|
+
await expect(
|
|
1587
|
+
MicrosoftTeamsUtil.handleBotInvokeActivity({
|
|
1588
|
+
activity: activity,
|
|
1589
|
+
turnContext: turn.turnContext,
|
|
1590
|
+
}),
|
|
1591
|
+
).resolves.toBeUndefined();
|
|
1592
|
+
|
|
1593
|
+
// The real project check read the monitor, and refused before creating.
|
|
1594
|
+
expect(findMonitors).toHaveBeenCalledTimes(1);
|
|
1595
|
+
expect(create.mock.calls).toHaveLength(0);
|
|
1596
|
+
|
|
1597
|
+
// One reply, and no removal: the form is there to pick again.
|
|
1598
|
+
expect(turn.events).toEqual([
|
|
1599
|
+
{ kind: "reply", text: details.unavailableReferenceReply },
|
|
1600
|
+
]);
|
|
1601
|
+
for (const reply of repliesOf(turn)) {
|
|
1602
|
+
expect(reply).not.toContain(FOREIGN_MONITOR_NAME);
|
|
1603
|
+
expect(reply).not.toContain(FOREIGN_MONITOR_ID);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// The log does name it: an operator needs to know which record it was.
|
|
1607
|
+
const attributes: LogAttributes = { projectId: PROJECT_ID.toString() };
|
|
1608
|
+
expect(errorLogCalls()).toStrictEqual([
|
|
1609
|
+
[
|
|
1610
|
+
`${details.failureLogSummary}: ProjectScopedReferenceException: This ${details.subject} references records that belong to a different project: Monitor "${FOREIGN_MONITOR_NAME}". Please pick values from this project and try again.`,
|
|
1611
|
+
attributes,
|
|
1612
|
+
],
|
|
1613
|
+
[expect.any(ProjectScopedReferenceException), attributes],
|
|
1614
|
+
]);
|
|
1615
|
+
},
|
|
1616
|
+
);
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
describe("an action done, but Teams refuses its confirmation: no failure reply, and the form is removed all the same", (): void => {
|
|
1620
|
+
/*
|
|
1621
|
+
* The note is there, the state changed, the policy paged, the incident or
|
|
1622
|
+
* maintenance event exists. A failure reply, or a form left on screen with
|
|
1623
|
+
* no confirmation under it, invites a second submit: a second note, a
|
|
1624
|
+
* second page, a second record. The 14 note, state and on-call submits used
|
|
1625
|
+
* to send their confirmation themselves, so a refused one threw: into
|
|
1626
|
+
* handleBotInvokeActivity ("Sorry, that action failed"), or, for scheduled
|
|
1627
|
+
* maintenance, into its handler's own catch ("An error occurred while
|
|
1628
|
+
* processing the action"), and the form stayed.
|
|
1629
|
+
*/
|
|
1630
|
+
// A turn in which Teams refuses every reply, the confirmation included.
|
|
1631
|
+
function refusingTurn(activity: JSONObject): FakeTurn {
|
|
1632
|
+
return createFakeTurn({
|
|
1633
|
+
activity: activity,
|
|
1634
|
+
replyError: teamsRefusal({
|
|
1635
|
+
statusCode: 502,
|
|
1636
|
+
code: "BadGateway",
|
|
1637
|
+
message: "Bad Gateway",
|
|
1638
|
+
}),
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// The one error line: the refused reply, which the action does not undo.
|
|
1643
|
+
const REFUSED_CONFIRMATION_LOGGED: Array<Array<unknown>> = [
|
|
1644
|
+
[`${REPLY_REFUSED_LOG_PREFIX}RestError 502 BadGateway: Bad Gateway`],
|
|
1645
|
+
];
|
|
1646
|
+
|
|
1647
|
+
test.each(SUBMITTED_FORMS)(
|
|
1648
|
+
"$name: the refused confirmation, then the removal - no failure reply, nothing thrown",
|
|
1649
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1650
|
+
const writeHappened: WriteCheck = form.stubWrite();
|
|
1651
|
+
const turn: FakeTurn = refusingTurn(submitActivity(form));
|
|
1652
|
+
|
|
1653
|
+
await expect(
|
|
1654
|
+
HANDLERS[form.family](form, turn.turnContext),
|
|
1655
|
+
).resolves.toBeUndefined();
|
|
1656
|
+
|
|
1657
|
+
writeHappened();
|
|
1658
|
+
expect(turn.events).toEqual([
|
|
1659
|
+
{ kind: "reply", text: form.confirmation },
|
|
1660
|
+
{ kind: "delete", activityId: FORM_ACTIVITY_ID },
|
|
1661
|
+
]);
|
|
1662
|
+
expect(errorLogCalls()).toStrictEqual(REFUSED_CONFIRMATION_LOGGED);
|
|
1663
|
+
},
|
|
1664
|
+
);
|
|
1665
|
+
|
|
1666
|
+
describe("delivered to handleBotInvokeActivity as Teams sends them", (): void => {
|
|
1667
|
+
beforeEach((): void => {
|
|
1668
|
+
stubInvokeLookups();
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
test.each(SUBMITTED_FORMS)(
|
|
1672
|
+
"$name: no 'Sorry, that action failed' is even attempted, and the form is removed",
|
|
1673
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1674
|
+
const writeHappened: WriteCheck = form.stubWrite();
|
|
1675
|
+
const activity: JSONObject = submitActivity(form);
|
|
1676
|
+
const turn: FakeTurn = refusingTurn(activity);
|
|
1677
|
+
|
|
1678
|
+
await expect(
|
|
1679
|
+
MicrosoftTeamsUtil.handleBotInvokeActivity({
|
|
1680
|
+
activity: activity,
|
|
1681
|
+
turnContext: turn.turnContext,
|
|
1682
|
+
}),
|
|
1683
|
+
).resolves.toBeUndefined();
|
|
1684
|
+
|
|
1685
|
+
writeHappened();
|
|
1686
|
+
// Every reply is recorded, refused or not: the confirmation was the only one.
|
|
1687
|
+
expect(turn.events).toEqual([
|
|
1688
|
+
{ kind: "reply", text: form.confirmation },
|
|
1689
|
+
{ kind: "delete", activityId: FORM_ACTIVITY_ID },
|
|
1690
|
+
]);
|
|
1691
|
+
expectNoFailureReply(turn);
|
|
1692
|
+
expect(errorLogCalls()).toStrictEqual(REFUSED_CONFIRMATION_LOGGED);
|
|
1693
|
+
},
|
|
1694
|
+
);
|
|
1695
|
+
});
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
describe("the same submits, delivered to handleBotInvokeActivity as Teams sends them", (): void => {
|
|
1699
|
+
beforeEach((): void => {
|
|
1700
|
+
stubInvokeLookups();
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
test.each(SUBMITTED_FORMS)(
|
|
1704
|
+
"$name: one bubble, the confirmation - no 'Sorry, that action failed' under it",
|
|
1705
|
+
async (form: SubmittedForm): Promise<void> => {
|
|
1706
|
+
const writeHappened: WriteCheck = form.stubWrite();
|
|
1707
|
+
const activity: JSONObject = submitActivity(form);
|
|
1708
|
+
const turn: FakeTurn = createFakeTurn({
|
|
1709
|
+
activity: activity,
|
|
1710
|
+
deleteError: formAlreadyGone(),
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
await expect(
|
|
1714
|
+
MicrosoftTeamsUtil.handleBotInvokeActivity({
|
|
1715
|
+
activity: activity,
|
|
1716
|
+
turnContext: turn.turnContext,
|
|
1717
|
+
}),
|
|
1718
|
+
).resolves.toBeUndefined();
|
|
1719
|
+
|
|
1720
|
+
writeHappened();
|
|
1721
|
+
// master: a failure reply under the confirmation, or instead of it.
|
|
1722
|
+
expect(turn.events).toEqual([
|
|
1723
|
+
{ kind: "reply", text: form.confirmation },
|
|
1724
|
+
{ kind: "delete", activityId: FORM_ACTIVITY_ID },
|
|
1725
|
+
]);
|
|
1726
|
+
expectNoFailureReply(turn);
|
|
1727
|
+
expect(errorLog).not.toHaveBeenCalled();
|
|
1728
|
+
},
|
|
1729
|
+
);
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
// Every TypeScript file below a directory, as a path relative to it.
|
|
1733
|
+
function typeScriptFilesBelow(
|
|
1734
|
+
directory: string,
|
|
1735
|
+
relativeDirectory: string = "",
|
|
1736
|
+
): Array<string> {
|
|
1737
|
+
const files: Array<string> = [];
|
|
1738
|
+
|
|
1739
|
+
for (const entry of fs.readdirSync(path.join(directory, relativeDirectory), {
|
|
1740
|
+
withFileTypes: true,
|
|
1741
|
+
})) {
|
|
1742
|
+
const relativePath: string = relativeDirectory
|
|
1743
|
+
? `${relativeDirectory}/${entry.name}`
|
|
1744
|
+
: entry.name;
|
|
1745
|
+
|
|
1746
|
+
if (entry.isDirectory()) {
|
|
1747
|
+
files.push(...typeScriptFilesBelow(directory, relativePath));
|
|
1748
|
+
} else if (entry.name.endsWith(".ts")) {
|
|
1749
|
+
files.push(relativePath);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
return files;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function countMatches(source: string, pattern: RegExp): number {
|
|
1757
|
+
return (source.match(pattern) || []).length;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const RAW_REMOVAL: RegExp = /\.\s*deleteActivity\s*\(/g;
|
|
1761
|
+
const BEST_EFFORT_REMOVAL: RegExp =
|
|
1762
|
+
/MicrosoftTeamsReplies\s*\.\s*deleteBestEffort\s*\(/g;
|
|
1763
|
+
|
|
1764
|
+
describe("the forms above are every form removal there is", (): void => {
|
|
1765
|
+
test("the Teams bot removes messages only through deleteBestEffort, and only in the submits driven above", (): void => {
|
|
1766
|
+
const teamsDirectory: string = path.join(
|
|
1767
|
+
__dirname,
|
|
1768
|
+
"../../../../../Server/Utils/Workspace/MicrosoftTeams",
|
|
1769
|
+
);
|
|
1770
|
+
const sourceFiles: Array<string> = typeScriptFilesBelow(teamsDirectory);
|
|
1771
|
+
|
|
1772
|
+
// The directory was read, subfolders included.
|
|
1773
|
+
expect(sourceFiles).toEqual(
|
|
1774
|
+
expect.arrayContaining([
|
|
1775
|
+
"MicrosoftTeams.ts",
|
|
1776
|
+
"MicrosoftTeamsReplies.ts",
|
|
1777
|
+
"MicrosoftTeamsCreateCommands.ts",
|
|
1778
|
+
...Object.values(FAMILY_SOURCE_FILES),
|
|
1779
|
+
]),
|
|
1780
|
+
);
|
|
1781
|
+
|
|
1782
|
+
for (const relativePath of sourceFiles) {
|
|
1783
|
+
const source: string = fs.readFileSync(
|
|
1784
|
+
path.join(teamsDirectory, relativePath),
|
|
1785
|
+
"utf8",
|
|
1786
|
+
);
|
|
1787
|
+
const rawRemovals: number = countMatches(source, RAW_REMOVAL);
|
|
1788
|
+
|
|
1789
|
+
if (relativePath === "MicrosoftTeamsReplies.ts") {
|
|
1790
|
+
// The one raw call there is, inside deleteBestEffort's try.
|
|
1791
|
+
expect(rawRemovals).toBe(1);
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
const submitsDrivenAbove: number = new Set(
|
|
1796
|
+
SUBMITTED_FORMS.filter((form: SubmittedForm) => {
|
|
1797
|
+
return FAMILY_SOURCE_FILES[form.family] === relativePath;
|
|
1798
|
+
}).map((form: SubmittedForm) => {
|
|
1799
|
+
return form.actionType;
|
|
1800
|
+
}),
|
|
1801
|
+
).size;
|
|
1802
|
+
|
|
1803
|
+
/*
|
|
1804
|
+
* A new handler that removes a message must do it best-effort and get
|
|
1805
|
+
* a row in SUBMITTED_FORMS; each submit above removes its form at
|
|
1806
|
+
* exactly one place.
|
|
1807
|
+
*/
|
|
1808
|
+
expect({
|
|
1809
|
+
file: relativePath,
|
|
1810
|
+
rawRemovals: rawRemovals,
|
|
1811
|
+
bestEffortRemovals: countMatches(source, BEST_EFFORT_REMOVAL),
|
|
1812
|
+
}).toEqual({
|
|
1813
|
+
file: relativePath,
|
|
1814
|
+
rawRemovals: 0,
|
|
1815
|
+
bestEffortRemovals: submitsDrivenAbove,
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
});
|