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