@oneuptime/common 14.0.10 → 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/CephCluster.ts +12 -0
- package/Models/DatabaseModels/CloudResource.ts +12 -0
- package/Models/DatabaseModels/DatabaseServer.ts +12 -0
- package/Models/DatabaseModels/DockerHost.ts +12 -0
- package/Models/DatabaseModels/DockerSwarmCluster.ts +12 -0
- 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 +12 -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 +14 -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 +12 -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 +12 -0
- package/Models/DatabaseModels/RumApplication.ts +12 -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 +12 -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/IncidentFormAPI.ts +376 -0
- package/Server/API/ProjectOIDC.ts +16 -25
- package/Server/API/ProjectSSO.ts +16 -24
- 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/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 +8 -0
- package/Server/Middleware/IncidentFormRateLimit.ts +847 -0
- package/Server/Middleware/UserAuthorization.ts +16 -38
- package/Server/Services/AIService.ts +10 -13
- 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/OpenTelemetryIngestService.ts +37 -4
- package/Server/Services/ProjectService.ts +0 -59
- package/Server/Services/StatusPagePrivateUserService.ts +1 -4
- package/Server/Services/StatusPageService.ts +1 -51
- package/Server/Services/TeamComplianceSettingService.ts +163 -42
- package/Server/Services/UserNotificationRuleService.ts +8 -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 +7 -1
- package/Server/Utils/AI/Remediation/RemediationExecutionRunner.ts +34 -14
- package/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.ts +69 -24
- package/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.ts +9 -0
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +53 -10
- package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +27 -10
- package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +27 -10
- 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/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/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/Incident.ts +3 -1
- package/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.ts +3 -1
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +10 -1
- 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 +174 -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 +5 -0
- 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/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/ResourceAiAgentAttentionServerParity.test.ts +555 -0
- package/Tests/App/Dashboard/ResourceAiAgentPage.test.tsx +298 -9
- package/Tests/App/Dashboard/ResourceAiAgentStatus.test.ts +453 -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/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 +28 -9
- package/Tests/Models/DatabaseModels/IncidentFormModel.test.ts +1250 -0
- package/Tests/Models/DatabaseModels/MessageQueueModels.test.ts +1983 -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/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/{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/AddIncidentFormsMigration.test.ts +438 -0
- package/Tests/Server/Infrastructure/Postgres/AddTeamComplianceRuleNotificationChannelsMigration.test.ts +255 -0
- package/Tests/Server/Infrastructure/Postgres/MessageQueueTablesMigration.test.ts +637 -0
- package/Tests/Server/Middleware/IncidentFormRateLimit.test.ts +1630 -0
- 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/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/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/RuleRun/LabelRuleEnginesGroupBRun.test.ts +56 -6
- package/Tests/Server/Services/RuleRun/OwnerRuleEnginesGroupBRun.test.ts +30 -1
- 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/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 +266 -25
- 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/RemediationExecutionRunner.test.ts +14 -25
- package/Tests/Server/Utils/AI/RemediationExecutionRunnerResourceMode.test.ts +2 -0
- package/Tests/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.test.ts +297 -15
- package/Tests/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.test.ts +10 -6
- 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/EditionEnforcement.test.ts +107 -147
- 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/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/Types/AI/AIAgentRunLimits.test.ts +255 -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/ResourceAiAgent/ResourceAiAccess.test.ts +5 -3
- 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/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +81 -1
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +17 -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/Types/AI/AIAgentRunLimits.ts +146 -0
- 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/Permission.ts +310 -0
- package/Types/ResourceAiAgent/ResourceAiAccess.ts +10 -4
- package/Types/Rules/RuleCriteriaFieldRegistry.ts +12 -0
- package/Types/Rules/RuleRun.ts +4 -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/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/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/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/CephCluster.js +12 -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 +12 -0
- package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js +12 -0
- package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerSwarmCluster.js +12 -0
- package/build/dist/Models/DatabaseModels/DockerSwarmCluster.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 +12 -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 +14 -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 +12 -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 +12 -0
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +12 -0
- package/build/dist/Models/DatabaseModels/RumApplication.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 +12 -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/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/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/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 +8 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.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/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/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/OpenTelemetryIngestService.js +25 -4
- package/build/dist/Server/Services/OpenTelemetryIngestService.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/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/UserNotificationRuleService.js +8 -1
- package/build/dist/Server/Services/UserNotificationRuleService.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 +7 -2
- package/build/dist/Server/Utils/AI/Remediation/RemediationCommandTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js +29 -14
- package/build/dist/Server/Utils/AI/Remediation/RemediationExecutionRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js +38 -14
- package/build/dist/Server/Utils/AI/ResourceAccess/InfrastructureInvestigationToolkit.js.map +1 -1
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js +2 -0
- package/build/dist/Server/Utils/AI/ResourceAccess/ResourceCommandJobRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +40 -10
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +19 -7
- package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +19 -7
- 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/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/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/Incident.js +3 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js +3 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/IncidentEpisode.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +10 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/AI/AIAgentRunLimits.js +91 -0
- package/build/dist/Types/AI/AIAgentRunLimits.js.map +1 -0
- 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/Permission.js +274 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js +10 -4
- package/build/dist/Types/ResourceAiAgent/ResourceAiAccess.js.map +1 -1
- 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/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/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/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/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,2520 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
jest,
|
|
7
|
+
test,
|
|
8
|
+
} from "@jest/globals";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* What a public submission turns into (IncidentFormService.submitPublicForm).
|
|
12
|
+
*
|
|
13
|
+
* A stranger's request becomes an incident that pages on-call, so every test
|
|
14
|
+
* here pins exactly what reaches IncidentService.create - field by field,
|
|
15
|
+
* with nothing extra - and what happens around it:
|
|
16
|
+
*
|
|
17
|
+
* - the checks, in order: link, form on, plan, network, captcha (only when
|
|
18
|
+
* the instance has one on), the answers, the severity;
|
|
19
|
+
* - the incident: the form's project whatever the body says, the
|
|
20
|
+
* reporter's title and description, the severity (the reporter's choice
|
|
21
|
+
* when allowed, else the form's, else the template's, else a 400), the
|
|
22
|
+
* template id so IncidentService applies the template, the answers to
|
|
23
|
+
* the fields the form asks and to nothing else, never on a status page
|
|
24
|
+
* or to its subscribers - and the template's owners, for IncidentService
|
|
25
|
+
* to add once the incident's channels exist, and to notify;
|
|
26
|
+
* - afterwards: the submission recorded, the reporter named in a private
|
|
27
|
+
* note with every value Markdown-escaped - and a failure in either
|
|
28
|
+
* logged, never thrown, because the incident already stands;
|
|
29
|
+
* - the answer: the incident's number and the form's success message.
|
|
30
|
+
*
|
|
31
|
+
* Only the database and the captcha provider are stubbed.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
jest.mock("../../../Server/Utils/Logger", () => {
|
|
35
|
+
return {
|
|
36
|
+
__esModule: true,
|
|
37
|
+
default: {
|
|
38
|
+
debug: jest.fn(),
|
|
39
|
+
info: jest.fn(),
|
|
40
|
+
warn: jest.fn(),
|
|
41
|
+
error: jest.fn(),
|
|
42
|
+
},
|
|
43
|
+
getLogAttributesFromRequest: jest.fn().mockReturnValue({}),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
import Incident from "../../../Models/DatabaseModels/Incident";
|
|
48
|
+
import IncidentCustomField from "../../../Models/DatabaseModels/IncidentCustomField";
|
|
49
|
+
import IncidentForm from "../../../Models/DatabaseModels/IncidentForm";
|
|
50
|
+
import IncidentFormSubmission from "../../../Models/DatabaseModels/IncidentFormSubmission";
|
|
51
|
+
import IncidentInternalNote from "../../../Models/DatabaseModels/IncidentInternalNote";
|
|
52
|
+
import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
|
|
53
|
+
import IncidentTemplate from "../../../Models/DatabaseModels/IncidentTemplate";
|
|
54
|
+
import IncidentTemplateOwnerTeam from "../../../Models/DatabaseModels/IncidentTemplateOwnerTeam";
|
|
55
|
+
import IncidentTemplateOwnerUser from "../../../Models/DatabaseModels/IncidentTemplateOwnerUser";
|
|
56
|
+
import Monitor from "../../../Models/DatabaseModels/Monitor";
|
|
57
|
+
import IncidentFormRateLimit, {
|
|
58
|
+
INCIDENT_FORM_RATE_LIMIT_UNAVAILABLE_MESSAGE,
|
|
59
|
+
INCIDENT_FORM_TOTAL_RATE_LIMIT_MESSAGE,
|
|
60
|
+
IncidentFormCeilingException,
|
|
61
|
+
} from "../../../Server/Middleware/IncidentFormRateLimit";
|
|
62
|
+
import IncidentCustomFieldService from "../../../Server/Services/IncidentCustomFieldService";
|
|
63
|
+
import IncidentFormService, {
|
|
64
|
+
INCIDENT_FORM_NETWORK_NOT_ALLOWED_MESSAGE,
|
|
65
|
+
INCIDENT_FORM_NOT_AVAILABLE_MESSAGE,
|
|
66
|
+
INCIDENT_FORM_NO_SEVERITY_MESSAGE,
|
|
67
|
+
INCIDENT_FORM_SUBMIT_FAILED_MESSAGE,
|
|
68
|
+
INCIDENT_FORM_TITLE_TOO_LONG_MESSAGE,
|
|
69
|
+
getIncidentFormReporterNote,
|
|
70
|
+
neutralizeIncidentFormReport,
|
|
71
|
+
} from "../../../Server/Services/IncidentFormService";
|
|
72
|
+
import IncidentFormSubmissionService from "../../../Server/Services/IncidentFormSubmissionService";
|
|
73
|
+
import IncidentInternalNoteService from "../../../Server/Services/IncidentInternalNoteService";
|
|
74
|
+
import IncidentService from "../../../Server/Services/IncidentService";
|
|
75
|
+
import IncidentSeverityService from "../../../Server/Services/IncidentSeverityService";
|
|
76
|
+
import IncidentTemplateOwnerTeamService from "../../../Server/Services/IncidentTemplateOwnerTeamService";
|
|
77
|
+
import IncidentTemplateOwnerUserService from "../../../Server/Services/IncidentTemplateOwnerUserService";
|
|
78
|
+
import IncidentTemplateService from "../../../Server/Services/IncidentTemplateService";
|
|
79
|
+
import Markdown, { MarkdownContentType } from "../../../Server/Types/Markdown";
|
|
80
|
+
import CaptchaUtil from "../../../Server/Utils/Captcha";
|
|
81
|
+
import logger from "../../../Server/Utils/Logger";
|
|
82
|
+
import SlackUtil from "../../../Server/Utils/Workspace/Slack/Slack";
|
|
83
|
+
import CustomFieldMappingSourceResource from "../../../Types/CustomField/CustomFieldMappingSourceResource";
|
|
84
|
+
import CustomFieldType from "../../../Types/CustomField/CustomFieldType";
|
|
85
|
+
import Email from "../../../Types/Email";
|
|
86
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
87
|
+
import Exception from "../../../Types/Exception/Exception";
|
|
88
|
+
import ForbiddenException from "../../../Types/Exception/ForbiddenException";
|
|
89
|
+
import NotFoundException from "../../../Types/Exception/NotFoundException";
|
|
90
|
+
import ServerException from "../../../Types/Exception/ServerException";
|
|
91
|
+
import ServiceUnavailableException from "../../../Types/Exception/ServiceUnavailableException";
|
|
92
|
+
import {
|
|
93
|
+
IncidentFormFieldSetting,
|
|
94
|
+
PublicIncidentFormSubmissionResult,
|
|
95
|
+
ValidatedIncidentFormSubmission,
|
|
96
|
+
formatIncidentFormSubmissionErrors,
|
|
97
|
+
} from "../../../Types/Incident/IncidentFormPublic";
|
|
98
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
99
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
100
|
+
import {
|
|
101
|
+
hrefsOf,
|
|
102
|
+
renderAsDashboard,
|
|
103
|
+
} from "../../Utils/Markdown/DashboardMarkdownRenderer";
|
|
104
|
+
import { Lexer, Token, Tokens, marked } from "marked";
|
|
105
|
+
|
|
106
|
+
type MockedFn = ReturnType<typeof jest.fn>;
|
|
107
|
+
|
|
108
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
109
|
+
"5d7f3c0a-4c55-4d3e-9a3e-2d4a7d1c9f01",
|
|
110
|
+
);
|
|
111
|
+
const OTHER_PROJECT_ID: string = "5d7f3c0a-4c55-4d3e-9a3e-2d4a7d1c9f02";
|
|
112
|
+
const FORM_ID: string = "a1b2c3d4-0000-4000-8000-0000000000f1";
|
|
113
|
+
const SHARE_KEY: string = "7c9e6679-7425-40de-944b-e07fc1f90ae7";
|
|
114
|
+
const OTHER_SHARE_KEY: string = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
|
115
|
+
const FORM_SEVERITY_ID: string = "b0000000-0000-4000-8000-0000000000f1";
|
|
116
|
+
const MINOR_SEVERITY_ID: string = "b0000000-0000-4000-8000-0000000000f2";
|
|
117
|
+
const TEMPLATE_ID: string = "c0000000-0000-4000-8000-0000000000f1";
|
|
118
|
+
const INCIDENT_ID: string = "e0000000-0000-4000-8000-0000000000f1";
|
|
119
|
+
const OWNER_USER_IDS: Array<string> = [
|
|
120
|
+
"f0000000-0000-4000-8000-0000000000a1",
|
|
121
|
+
"f0000000-0000-4000-8000-0000000000a2",
|
|
122
|
+
];
|
|
123
|
+
const OWNER_TEAM_ID: string = "f0000000-0000-4000-8000-0000000000b1";
|
|
124
|
+
const CLIENT_IP: string = "203.0.113.7";
|
|
125
|
+
const CAPTCHA_IP: string = "203.0.113.8";
|
|
126
|
+
|
|
127
|
+
// A column read back from Postgres as NULL, which the model's types omit.
|
|
128
|
+
function setNull(
|
|
129
|
+
model: IncidentForm | Incident | IncidentTemplate,
|
|
130
|
+
column: string,
|
|
131
|
+
): void {
|
|
132
|
+
(model as unknown as Record<string, unknown>)[column] = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildForm(data: Partial<IncidentForm> = {}): IncidentForm {
|
|
136
|
+
const form: IncidentForm = new IncidentForm();
|
|
137
|
+
form._id = FORM_ID;
|
|
138
|
+
form.projectId = PROJECT_ID;
|
|
139
|
+
form.name = "Report a Problem";
|
|
140
|
+
form.isEnabled = true;
|
|
141
|
+
form.shareKey = new ObjectID(SHARE_KEY);
|
|
142
|
+
form.incidentSeverityId = new ObjectID(FORM_SEVERITY_ID);
|
|
143
|
+
form.allowReporterToChooseSeverity = false;
|
|
144
|
+
form.descriptionSetting = IncidentFormFieldSetting.Optional;
|
|
145
|
+
form.customFieldSettings = {};
|
|
146
|
+
form.isReporterDetailsRequired = true;
|
|
147
|
+
form.successMessage = "Thanks - **we are on it**.";
|
|
148
|
+
form.ipWhitelist = "";
|
|
149
|
+
Object.assign(form, data);
|
|
150
|
+
return form;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function withTemplate(data: Partial<IncidentForm> = {}): IncidentForm {
|
|
154
|
+
return buildForm({ incidentTemplateId: new ObjectID(TEMPLATE_ID), ...data });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function customField(data: {
|
|
158
|
+
name: string;
|
|
159
|
+
variableKey: string;
|
|
160
|
+
customFieldType: CustomFieldType;
|
|
161
|
+
sortOrder: number;
|
|
162
|
+
dropdownOptions?: string;
|
|
163
|
+
}): IncidentCustomField {
|
|
164
|
+
const field: IncidentCustomField = new IncidentCustomField();
|
|
165
|
+
field._id = ObjectID.generate().toString();
|
|
166
|
+
field.projectId = PROJECT_ID;
|
|
167
|
+
field.name = data.name;
|
|
168
|
+
field.variableKey = data.variableKey;
|
|
169
|
+
field.customFieldType = data.customFieldType;
|
|
170
|
+
field.sortOrder = data.sortOrder;
|
|
171
|
+
field.showOnCreate = true;
|
|
172
|
+
field.isRequiredOnCreate = true;
|
|
173
|
+
|
|
174
|
+
if (data.dropdownOptions !== undefined) {
|
|
175
|
+
field.dropdownOptions = data.dropdownOptions;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return field;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const PROJECT_FIELDS: Array<IncidentCustomField> = [
|
|
182
|
+
customField({
|
|
183
|
+
name: "Impact",
|
|
184
|
+
variableKey: "impact",
|
|
185
|
+
customFieldType: CustomFieldType.Dropdown,
|
|
186
|
+
sortOrder: 1,
|
|
187
|
+
dropdownOptions: "Low\nHigh",
|
|
188
|
+
}),
|
|
189
|
+
customField({
|
|
190
|
+
name: "Region",
|
|
191
|
+
variableKey: "region",
|
|
192
|
+
customFieldType: CustomFieldType.Text,
|
|
193
|
+
sortOrder: 2,
|
|
194
|
+
}),
|
|
195
|
+
customField({
|
|
196
|
+
name: "Acknowledged",
|
|
197
|
+
variableKey: "acknowledged",
|
|
198
|
+
customFieldType: CustomFieldType.Boolean,
|
|
199
|
+
sortOrder: 3,
|
|
200
|
+
}),
|
|
201
|
+
customField({
|
|
202
|
+
name: "Secret Escalation Path",
|
|
203
|
+
variableKey: "secret_escalation_path",
|
|
204
|
+
customFieldType: CustomFieldType.Text,
|
|
205
|
+
sortOrder: 4,
|
|
206
|
+
}),
|
|
207
|
+
customField({
|
|
208
|
+
name: "Details",
|
|
209
|
+
variableKey: "details",
|
|
210
|
+
customFieldType: CustomFieldType.Markdown,
|
|
211
|
+
sortOrder: 5,
|
|
212
|
+
}),
|
|
213
|
+
customField({
|
|
214
|
+
name: "Notes",
|
|
215
|
+
variableKey: "notes",
|
|
216
|
+
customFieldType: CustomFieldType.LongText,
|
|
217
|
+
sortOrder: 6,
|
|
218
|
+
}),
|
|
219
|
+
customField({
|
|
220
|
+
name: "Systems",
|
|
221
|
+
variableKey: "systems",
|
|
222
|
+
customFieldType: CustomFieldType.MultiSelectDropdown,
|
|
223
|
+
sortOrder: 7,
|
|
224
|
+
dropdownOptions: "API\nWeb",
|
|
225
|
+
}),
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
const FORM_QUESTIONS: Record<string, string> = {
|
|
229
|
+
impact: "Required",
|
|
230
|
+
region: "Optional",
|
|
231
|
+
secret_escalation_path: "Hidden",
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function severity(id: string, name: string, order: number): IncidentSeverity {
|
|
235
|
+
const row: IncidentSeverity = new IncidentSeverity();
|
|
236
|
+
row._id = id;
|
|
237
|
+
row.name = name;
|
|
238
|
+
row.order = order;
|
|
239
|
+
row.projectId = PROJECT_ID;
|
|
240
|
+
return row;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const PROJECT_SEVERITIES: Array<IncidentSeverity> = [
|
|
244
|
+
severity(FORM_SEVERITY_ID, "Critical", 1),
|
|
245
|
+
severity(MINOR_SEVERITY_ID, "Minor", 2),
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
function ownerUser(userId: string): IncidentTemplateOwnerUser {
|
|
249
|
+
const owner: IncidentTemplateOwnerUser = new IncidentTemplateOwnerUser();
|
|
250
|
+
owner.userId = new ObjectID(userId);
|
|
251
|
+
return owner;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function ownerTeam(teamId: string): IncidentTemplateOwnerTeam {
|
|
255
|
+
const owner: IncidentTemplateOwnerTeam = new IncidentTemplateOwnerTeam();
|
|
256
|
+
owner.teamId = new ObjectID(teamId);
|
|
257
|
+
return owner;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function templateWithSeverity(severityId: string | null): IncidentTemplate {
|
|
261
|
+
const template: IncidentTemplate = new IncidentTemplate();
|
|
262
|
+
template._id = TEMPLATE_ID;
|
|
263
|
+
|
|
264
|
+
if (severityId) {
|
|
265
|
+
template.incidentSeverityId = new ObjectID(severityId);
|
|
266
|
+
} else {
|
|
267
|
+
setNull(template, "incidentSeverityId");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return template;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const VALID_ANSWERS: JSONObject = {
|
|
274
|
+
title: " Checkout is down ",
|
|
275
|
+
description: "Every order fails\n\n at step 3",
|
|
276
|
+
reporterName: "Jane Doe",
|
|
277
|
+
reporterEmail: "Jane.Doe@Example.com",
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
let storedForm: IncidentForm | null = null;
|
|
281
|
+
let formFindOneBy: MockedFn;
|
|
282
|
+
let customFieldFindBy: MockedFn;
|
|
283
|
+
let severityFindBy: MockedFn;
|
|
284
|
+
let templateFindOneBy: MockedFn;
|
|
285
|
+
let incidentCreate: MockedFn;
|
|
286
|
+
let addOwners: MockedFn;
|
|
287
|
+
let ownerUserFindBy: MockedFn;
|
|
288
|
+
let ownerTeamFindBy: MockedFn;
|
|
289
|
+
let submissionCreate: MockedFn;
|
|
290
|
+
let noteCreate: MockedFn;
|
|
291
|
+
let captchaEnabled: MockedFn;
|
|
292
|
+
let verifyCaptcha: MockedFn;
|
|
293
|
+
let reserveFormSubmission: MockedFn;
|
|
294
|
+
let createdIncident: Incident;
|
|
295
|
+
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
storedForm = buildForm();
|
|
298
|
+
|
|
299
|
+
createdIncident = new Incident();
|
|
300
|
+
createdIncident._id = INCIDENT_ID;
|
|
301
|
+
createdIncident.projectId = PROJECT_ID;
|
|
302
|
+
createdIncident.incidentNumber = 42;
|
|
303
|
+
createdIncident.incidentNumberWithPrefix = "INC-42";
|
|
304
|
+
|
|
305
|
+
formFindOneBy = jest
|
|
306
|
+
.spyOn(IncidentFormService, "findOneBy")
|
|
307
|
+
.mockImplementation((async (findBy: {
|
|
308
|
+
query: { shareKey?: ObjectID };
|
|
309
|
+
}): Promise<IncidentForm | null> => {
|
|
310
|
+
if (
|
|
311
|
+
storedForm &&
|
|
312
|
+
findBy.query.shareKey?.toString() === storedForm.shareKey?.toString()
|
|
313
|
+
) {
|
|
314
|
+
return storedForm;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return null;
|
|
318
|
+
}) as never) as unknown as MockedFn;
|
|
319
|
+
|
|
320
|
+
jest
|
|
321
|
+
.spyOn(IncidentFormService, "isProjectOnPlan")
|
|
322
|
+
.mockResolvedValue(true as never);
|
|
323
|
+
|
|
324
|
+
customFieldFindBy = jest
|
|
325
|
+
.spyOn(IncidentCustomFieldService, "findBy")
|
|
326
|
+
.mockResolvedValue(PROJECT_FIELDS as never) as unknown as MockedFn;
|
|
327
|
+
|
|
328
|
+
severityFindBy = jest
|
|
329
|
+
.spyOn(IncidentSeverityService, "findBy")
|
|
330
|
+
.mockResolvedValue(PROJECT_SEVERITIES as never) as unknown as MockedFn;
|
|
331
|
+
|
|
332
|
+
templateFindOneBy = jest
|
|
333
|
+
.spyOn(IncidentTemplateService, "findOneBy")
|
|
334
|
+
.mockResolvedValue(
|
|
335
|
+
templateWithSeverity(MINOR_SEVERITY_ID) as never,
|
|
336
|
+
) as unknown as MockedFn;
|
|
337
|
+
|
|
338
|
+
incidentCreate = jest
|
|
339
|
+
.spyOn(IncidentService, "create")
|
|
340
|
+
.mockImplementation((async (): Promise<Incident> => {
|
|
341
|
+
return createdIncident;
|
|
342
|
+
}) as never) as unknown as MockedFn;
|
|
343
|
+
|
|
344
|
+
addOwners = jest
|
|
345
|
+
.spyOn(IncidentService, "addOwners")
|
|
346
|
+
.mockResolvedValue(undefined as never) as unknown as MockedFn;
|
|
347
|
+
|
|
348
|
+
ownerUserFindBy = jest
|
|
349
|
+
.spyOn(IncidentTemplateOwnerUserService, "findBy")
|
|
350
|
+
.mockResolvedValue(
|
|
351
|
+
OWNER_USER_IDS.map(ownerUser) as never,
|
|
352
|
+
) as unknown as MockedFn;
|
|
353
|
+
|
|
354
|
+
ownerTeamFindBy = jest
|
|
355
|
+
.spyOn(IncidentTemplateOwnerTeamService, "findBy")
|
|
356
|
+
.mockResolvedValue([
|
|
357
|
+
ownerTeam(OWNER_TEAM_ID),
|
|
358
|
+
] as never) as unknown as MockedFn;
|
|
359
|
+
|
|
360
|
+
submissionCreate = jest
|
|
361
|
+
.spyOn(IncidentFormSubmissionService, "create")
|
|
362
|
+
.mockImplementation((async (createBy: {
|
|
363
|
+
data: IncidentFormSubmission;
|
|
364
|
+
}): Promise<IncidentFormSubmission> => {
|
|
365
|
+
return createBy.data;
|
|
366
|
+
}) as never) as unknown as MockedFn;
|
|
367
|
+
|
|
368
|
+
noteCreate = jest
|
|
369
|
+
.spyOn(IncidentInternalNoteService, "create")
|
|
370
|
+
.mockImplementation((async (createBy: {
|
|
371
|
+
data: IncidentInternalNote;
|
|
372
|
+
}): Promise<IncidentInternalNote> => {
|
|
373
|
+
return createBy.data;
|
|
374
|
+
}) as never) as unknown as MockedFn;
|
|
375
|
+
|
|
376
|
+
captchaEnabled = jest
|
|
377
|
+
.spyOn(CaptchaUtil, "isCaptchaEnabled")
|
|
378
|
+
.mockReturnValue(false) as unknown as MockedFn;
|
|
379
|
+
|
|
380
|
+
verifyCaptcha = jest
|
|
381
|
+
.spyOn(CaptchaUtil, "verifyCaptcha")
|
|
382
|
+
.mockResolvedValue(undefined as never) as unknown as MockedFn;
|
|
383
|
+
|
|
384
|
+
/*
|
|
385
|
+
* The form's hourly ceiling is counted in Redis, which this suite has
|
|
386
|
+
* none of (left real, it would fail closed with a 503); what it is asked,
|
|
387
|
+
* and when, is pinned below.
|
|
388
|
+
*/
|
|
389
|
+
reserveFormSubmission = jest
|
|
390
|
+
.spyOn(IncidentFormRateLimit, "reserveFormSubmission")
|
|
391
|
+
.mockResolvedValue(undefined as never) as unknown as MockedFn;
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
afterEach(() => {
|
|
395
|
+
jest.restoreAllMocks();
|
|
396
|
+
jest.clearAllMocks();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
function submit(
|
|
400
|
+
data: {
|
|
401
|
+
answers?: unknown;
|
|
402
|
+
captchaToken?: string;
|
|
403
|
+
shareKey?: string | undefined;
|
|
404
|
+
clientIp?: string | undefined;
|
|
405
|
+
} = {},
|
|
406
|
+
): Promise<PublicIncidentFormSubmissionResult> {
|
|
407
|
+
const request: { data: unknown; captchaToken?: string } = {
|
|
408
|
+
data: "answers" in data ? data.answers : VALID_ANSWERS,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
if (data.captchaToken !== undefined) {
|
|
412
|
+
request.captchaToken = data.captchaToken;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return IncidentFormService.submitPublicForm({
|
|
416
|
+
shareKey: "shareKey" in data ? data.shareKey : SHARE_KEY,
|
|
417
|
+
request: request as never,
|
|
418
|
+
clientIp: "clientIp" in data ? data.clientIp : CLIENT_IP,
|
|
419
|
+
captchaRemoteIp: CAPTCHA_IP,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function refusal(
|
|
424
|
+
promise: Promise<unknown>,
|
|
425
|
+
): Promise<Exception | undefined> {
|
|
426
|
+
try {
|
|
427
|
+
await promise;
|
|
428
|
+
} catch (err) {
|
|
429
|
+
return err as Exception;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The call IncidentService.create received.
|
|
436
|
+
function createCall(): {
|
|
437
|
+
data: Incident;
|
|
438
|
+
props: Record<string, unknown>;
|
|
439
|
+
miscDataProps?: JSONObject | undefined;
|
|
440
|
+
} {
|
|
441
|
+
expect(incidentCreate).toHaveBeenCalledTimes(1);
|
|
442
|
+
|
|
443
|
+
return incidentCreate.mock.calls[0]![0] as {
|
|
444
|
+
data: Incident;
|
|
445
|
+
props: Record<string, unknown>;
|
|
446
|
+
miscDataProps?: JSONObject | undefined;
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/*
|
|
451
|
+
* Every column set on the incident handed to IncidentService.create, as
|
|
452
|
+
* plain values. Asserting this whole object, rather than a column or two,
|
|
453
|
+
* is what proves nothing else in the request reached the incident. (Only
|
|
454
|
+
* table columns: the model's own bookkeeping, such as isPermissionIf, is
|
|
455
|
+
* never written.)
|
|
456
|
+
*/
|
|
457
|
+
function createdColumns(): Record<string, unknown> {
|
|
458
|
+
const incident: Incident = createCall().data;
|
|
459
|
+
const columns: Record<string, unknown> = {};
|
|
460
|
+
|
|
461
|
+
for (const [key, value] of Object.entries(incident)) {
|
|
462
|
+
if (value === undefined || !incident.hasColumn(key)) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
columns[key] = value instanceof ObjectID ? value.toString() : value;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return columns;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const PLAIN_INCIDENT: Record<string, unknown> = {
|
|
473
|
+
projectId: PROJECT_ID.toString(),
|
|
474
|
+
title: "Checkout is down",
|
|
475
|
+
description: "Every order fails\n\n at step 3",
|
|
476
|
+
incidentSeverityId: FORM_SEVERITY_ID,
|
|
477
|
+
customFields: {},
|
|
478
|
+
isVisibleOnStatusPage: false,
|
|
479
|
+
shouldStatusPageSubscribersBeNotifiedOnIncidentCreated: false,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
describe("IncidentFormService.submitPublicForm - the incident it declares", () => {
|
|
483
|
+
test("a form without a template: exactly the reporter's incident, in the form's project, as root", async () => {
|
|
484
|
+
await submit();
|
|
485
|
+
|
|
486
|
+
expect(createdColumns()).toEqual(PLAIN_INCIDENT);
|
|
487
|
+
expect(createCall().props).toEqual({ isRoot: true });
|
|
488
|
+
expect(createCall().data).toBeInstanceOf(Incident);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("a form with a template: the same, plus the template's id for IncidentService to apply", async () => {
|
|
492
|
+
storedForm = withTemplate();
|
|
493
|
+
|
|
494
|
+
await submit();
|
|
495
|
+
|
|
496
|
+
expect(createdColumns()).toEqual({
|
|
497
|
+
...PLAIN_INCIDENT,
|
|
498
|
+
createdIncidentTemplateId: TEMPLATE_ID,
|
|
499
|
+
});
|
|
500
|
+
expect(typeof createCall().data.createdIncidentTemplateId).toBe("string");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
/*
|
|
504
|
+
* IncidentService only applies a template when no state is given, so a
|
|
505
|
+
* state here would silently drop the template's monitors, owners' policies
|
|
506
|
+
* and everything else it sets.
|
|
507
|
+
*/
|
|
508
|
+
test("never sets a state, so IncidentService's template branch runs", async () => {
|
|
509
|
+
storedForm = withTemplate();
|
|
510
|
+
|
|
511
|
+
await submit();
|
|
512
|
+
|
|
513
|
+
expect(createCall().data.currentIncidentStateId).toBeUndefined();
|
|
514
|
+
expect(createCall().data.currentIncidentState).toBeUndefined();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("names no creating user: nobody signed in to send it", async () => {
|
|
518
|
+
await submit();
|
|
519
|
+
|
|
520
|
+
expect(createCall().data.createdByUserId).toBeUndefined();
|
|
521
|
+
expect(createCall().data.createdByUser).toBeUndefined();
|
|
522
|
+
expect(createCall().props["userId"]).toBeUndefined();
|
|
523
|
+
expect(createCall().props["tenantId"]).toBeUndefined();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("keeps the incident off every status page, and quiet to their subscribers, whatever the template says", async () => {
|
|
527
|
+
storedForm = withTemplate();
|
|
528
|
+
|
|
529
|
+
await submit();
|
|
530
|
+
|
|
531
|
+
expect(createCall().data.isVisibleOnStatusPage).toBe(false);
|
|
532
|
+
expect(
|
|
533
|
+
createCall().data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
|
534
|
+
).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
/*
|
|
538
|
+
* The body is a stranger's. Nothing in it but the answers the form asks
|
|
539
|
+
* for may reach the incident - least of all which project it lands in.
|
|
540
|
+
*/
|
|
541
|
+
test("ignores everything in the body the form does not ask for", async () => {
|
|
542
|
+
await submit({
|
|
543
|
+
answers: {
|
|
544
|
+
...VALID_ANSWERS,
|
|
545
|
+
projectId: OTHER_PROJECT_ID,
|
|
546
|
+
currentIncidentStateId: ObjectID.generate().toString(),
|
|
547
|
+
isPrivate: true,
|
|
548
|
+
isVisibleOnStatusPage: true,
|
|
549
|
+
shouldStatusPageSubscribersBeNotifiedOnIncidentCreated: true,
|
|
550
|
+
createdByUserId: ObjectID.generate().toString(),
|
|
551
|
+
createdIncidentTemplateId: ObjectID.generate().toString(),
|
|
552
|
+
monitors: [{ _id: ObjectID.generate().toString() }],
|
|
553
|
+
rootCause: "planted",
|
|
554
|
+
incidentSeverityId: MINOR_SEVERITY_ID,
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(createdColumns()).toEqual(PLAIN_INCIDENT);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe("IncidentFormService.submitPublicForm - severity", () => {
|
|
563
|
+
test("uses the reporter's choice when the form lets them choose", async () => {
|
|
564
|
+
storedForm = buildForm({ allowReporterToChooseSeverity: true });
|
|
565
|
+
|
|
566
|
+
await submit({
|
|
567
|
+
answers: {
|
|
568
|
+
...VALID_ANSWERS,
|
|
569
|
+
incidentSeverityId: MINOR_SEVERITY_ID.toUpperCase(),
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
expect(createdColumns()["incidentSeverityId"]).toBe(MINOR_SEVERITY_ID);
|
|
574
|
+
expect(createCall().data.incidentSeverityId).toBeInstanceOf(ObjectID);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("checks the choice against the form's project's severities, read as root", async () => {
|
|
578
|
+
storedForm = buildForm({ allowReporterToChooseSeverity: true });
|
|
579
|
+
|
|
580
|
+
await submit({
|
|
581
|
+
answers: { ...VALID_ANSWERS, incidentSeverityId: MINOR_SEVERITY_ID },
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const findBy: {
|
|
585
|
+
query: Record<string, unknown>;
|
|
586
|
+
props: Record<string, unknown>;
|
|
587
|
+
} = severityFindBy.mock.calls[0]![0] as never;
|
|
588
|
+
|
|
589
|
+
expect(findBy.query).toEqual({ projectId: PROJECT_ID });
|
|
590
|
+
expect(findBy.props).toEqual({ isRoot: true });
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("refuses a choice that is not one of the project's severities, declaring nothing", async () => {
|
|
594
|
+
storedForm = buildForm({ allowReporterToChooseSeverity: true });
|
|
595
|
+
|
|
596
|
+
const error: Exception | undefined = await refusal(
|
|
597
|
+
submit({
|
|
598
|
+
answers: {
|
|
599
|
+
...VALID_ANSWERS,
|
|
600
|
+
incidentSeverityId: ObjectID.generate().toString(),
|
|
601
|
+
},
|
|
602
|
+
}),
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
606
|
+
expect(error?.code).toBe(400);
|
|
607
|
+
expect(error?.message).toBe(
|
|
608
|
+
"Severity must be one of the severities this form lists.",
|
|
609
|
+
);
|
|
610
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("uses the form's severity when the reporter may choose but did not", async () => {
|
|
614
|
+
storedForm = buildForm({ allowReporterToChooseSeverity: true });
|
|
615
|
+
|
|
616
|
+
await submit({ answers: { ...VALID_ANSWERS, incidentSeverityId: "" } });
|
|
617
|
+
|
|
618
|
+
expect(createdColumns()["incidentSeverityId"]).toBe(FORM_SEVERITY_ID);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("ignores a choice, and reads no severities, when the form sets the severity", async () => {
|
|
622
|
+
await submit({
|
|
623
|
+
answers: { ...VALID_ANSWERS, incidentSeverityId: MINOR_SEVERITY_ID },
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
expect(createdColumns()["incidentSeverityId"]).toBe(FORM_SEVERITY_ID);
|
|
627
|
+
expect(severityFindBy).not.toHaveBeenCalled();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("ignores even an invalid choice when the form does not offer one", async () => {
|
|
631
|
+
await submit({
|
|
632
|
+
answers: { ...VALID_ANSWERS, incidentSeverityId: "not-a-severity" },
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
expect(createdColumns()["incidentSeverityId"]).toBe(FORM_SEVERITY_ID);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
/*
|
|
639
|
+
* Deleting the form's severity clears the column. With a template that
|
|
640
|
+
* has a severity, the incident is declared without one here and
|
|
641
|
+
* IncidentService's template branch fills it in.
|
|
642
|
+
*/
|
|
643
|
+
test("leaves the severity to the template when the form's own was deleted", async () => {
|
|
644
|
+
storedForm = withTemplate();
|
|
645
|
+
setNull(storedForm, "incidentSeverityId");
|
|
646
|
+
|
|
647
|
+
await submit();
|
|
648
|
+
|
|
649
|
+
expect(createdColumns()).toEqual({
|
|
650
|
+
...PLAIN_INCIDENT,
|
|
651
|
+
incidentSeverityId: undefined,
|
|
652
|
+
createdIncidentTemplateId: TEMPLATE_ID,
|
|
653
|
+
});
|
|
654
|
+
expect(createCall().data.incidentSeverityId).toBeUndefined();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("looks the template's severity up in the form's project, as root", async () => {
|
|
658
|
+
storedForm = withTemplate();
|
|
659
|
+
setNull(storedForm, "incidentSeverityId");
|
|
660
|
+
|
|
661
|
+
await submit();
|
|
662
|
+
|
|
663
|
+
expect(templateFindOneBy).toHaveBeenCalledWith({
|
|
664
|
+
query: { _id: TEMPLATE_ID, projectId: PROJECT_ID },
|
|
665
|
+
select: { incidentSeverityId: true },
|
|
666
|
+
props: { isRoot: true },
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test("does not read the template when the form has a severity", async () => {
|
|
671
|
+
storedForm = withTemplate();
|
|
672
|
+
|
|
673
|
+
await submit();
|
|
674
|
+
|
|
675
|
+
expect(templateFindOneBy).not.toHaveBeenCalled();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test.each([
|
|
679
|
+
[
|
|
680
|
+
"no template",
|
|
681
|
+
(): IncidentForm => {
|
|
682
|
+
return buildForm();
|
|
683
|
+
},
|
|
684
|
+
null,
|
|
685
|
+
],
|
|
686
|
+
[
|
|
687
|
+
"a template without a severity",
|
|
688
|
+
(): IncidentForm => {
|
|
689
|
+
return withTemplate();
|
|
690
|
+
},
|
|
691
|
+
templateWithSeverity(null),
|
|
692
|
+
],
|
|
693
|
+
[
|
|
694
|
+
"a template that is not in the form's project",
|
|
695
|
+
(): IncidentForm => {
|
|
696
|
+
return withTemplate();
|
|
697
|
+
},
|
|
698
|
+
null,
|
|
699
|
+
],
|
|
700
|
+
])(
|
|
701
|
+
"refuses, declaring nothing, when no severity is left: %s",
|
|
702
|
+
async (
|
|
703
|
+
_label: string,
|
|
704
|
+
form: () => IncidentForm,
|
|
705
|
+
template: IncidentTemplate | null,
|
|
706
|
+
) => {
|
|
707
|
+
storedForm = form();
|
|
708
|
+
setNull(storedForm, "incidentSeverityId");
|
|
709
|
+
templateFindOneBy.mockResolvedValue(template as never);
|
|
710
|
+
|
|
711
|
+
const error: Exception | undefined = await refusal(submit());
|
|
712
|
+
|
|
713
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
714
|
+
expect(error?.code).toBe(400);
|
|
715
|
+
expect(error?.message).toBe(INCIDENT_FORM_NO_SEVERITY_MESSAGE);
|
|
716
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
717
|
+
},
|
|
718
|
+
);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe("IncidentFormService.submitPublicForm - title and description", () => {
|
|
722
|
+
test("trims the title and keeps it on one line", async () => {
|
|
723
|
+
await submit({
|
|
724
|
+
answers: { ...VALID_ANSWERS, title: "\n Checkout\tis down \r\n" },
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
expect(createCall().data.title).toBe("Checkout is down");
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test.each([
|
|
731
|
+
["missing", undefined],
|
|
732
|
+
["blank", " "],
|
|
733
|
+
])(
|
|
734
|
+
"refuses a %s title, declaring nothing",
|
|
735
|
+
async (_label: string, title: string | undefined) => {
|
|
736
|
+
const answers: JSONObject = { ...VALID_ANSWERS };
|
|
737
|
+
|
|
738
|
+
if (title === undefined) {
|
|
739
|
+
delete answers["title"];
|
|
740
|
+
} else {
|
|
741
|
+
answers["title"] = title;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const error: Exception | undefined = await refusal(submit({ answers }));
|
|
745
|
+
|
|
746
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
747
|
+
expect(error?.message).toBe("Title is required.");
|
|
748
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
test("a hidden description question: whatever was sent is ignored, so a template's description can apply", async () => {
|
|
753
|
+
storedForm = withTemplate({
|
|
754
|
+
descriptionSetting: IncidentFormFieldSetting.Hidden,
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// VALID_ANSWERS carries a description; the form does not ask for one.
|
|
758
|
+
await submit();
|
|
759
|
+
|
|
760
|
+
expect(createCall().data.description).toBeUndefined();
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test.each([
|
|
764
|
+
["left out", undefined],
|
|
765
|
+
["empty", ""],
|
|
766
|
+
["only whitespace", " \n\n "],
|
|
767
|
+
])(
|
|
768
|
+
"an optional description %s is left undefined, not sent empty",
|
|
769
|
+
async (_label: string, description: string | undefined) => {
|
|
770
|
+
const answers: JSONObject = { ...VALID_ANSWERS };
|
|
771
|
+
|
|
772
|
+
if (description === undefined) {
|
|
773
|
+
delete answers["description"];
|
|
774
|
+
} else {
|
|
775
|
+
answers["description"] = description;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
storedForm = withTemplate();
|
|
779
|
+
|
|
780
|
+
await submit({ answers });
|
|
781
|
+
|
|
782
|
+
expect(createCall().data.description).toBeUndefined();
|
|
783
|
+
},
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
test("a required description that is missing is refused, declaring nothing", async () => {
|
|
787
|
+
storedForm = buildForm({
|
|
788
|
+
descriptionSetting: IncidentFormFieldSetting.Required,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
const answers: JSONObject = { ...VALID_ANSWERS };
|
|
792
|
+
delete answers["description"];
|
|
793
|
+
|
|
794
|
+
const error: Exception | undefined = await refusal(submit({ answers }));
|
|
795
|
+
|
|
796
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
797
|
+
expect(error?.message).toBe("Description is required.");
|
|
798
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("a required description that was written is declared as written", async () => {
|
|
802
|
+
storedForm = buildForm({
|
|
803
|
+
descriptionSetting: IncidentFormFieldSetting.Required,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
await submit();
|
|
807
|
+
|
|
808
|
+
expect(createCall().data.description).toBe(
|
|
809
|
+
"Every order fails\n\n at step 3",
|
|
810
|
+
);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
describe("IncidentFormService.submitPublicForm - custom fields", () => {
|
|
815
|
+
test("stores the answers to the fields the form asks, and drops every other key", async () => {
|
|
816
|
+
storedForm = buildForm({ customFieldSettings: FORM_QUESTIONS });
|
|
817
|
+
|
|
818
|
+
await submit({
|
|
819
|
+
answers: {
|
|
820
|
+
...VALID_ANSWERS,
|
|
821
|
+
customFields: {
|
|
822
|
+
Impact: "High",
|
|
823
|
+
Region: " EU-West ",
|
|
824
|
+
"Secret Escalation Path": "planted",
|
|
825
|
+
Acknowledged: true,
|
|
826
|
+
"No Such Field": "planted",
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
expect(createCall().data.customFields).toEqual({
|
|
832
|
+
Impact: "High",
|
|
833
|
+
Region: "EU-West",
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("reads the project's fields from the form's own project, as root", async () => {
|
|
838
|
+
storedForm = buildForm({ customFieldSettings: FORM_QUESTIONS });
|
|
839
|
+
|
|
840
|
+
await submit({
|
|
841
|
+
answers: { ...VALID_ANSWERS, customFields: { Impact: "Low" } },
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
const findBy: {
|
|
845
|
+
query: Record<string, unknown>;
|
|
846
|
+
props: Record<string, unknown>;
|
|
847
|
+
} = customFieldFindBy.mock.calls[0]![0] as never;
|
|
848
|
+
|
|
849
|
+
expect(findBy.query).toEqual({ projectId: PROJECT_ID });
|
|
850
|
+
expect(findBy.props).toEqual({ isRoot: true });
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
test("leaves an unanswered optional field out, so a template's value for it still applies", async () => {
|
|
854
|
+
storedForm = withTemplate({ customFieldSettings: FORM_QUESTIONS });
|
|
855
|
+
|
|
856
|
+
await submit({
|
|
857
|
+
answers: {
|
|
858
|
+
...VALID_ANSWERS,
|
|
859
|
+
customFields: { Impact: "Low", Region: "" },
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
expect(createCall().data.customFields).toEqual({ Impact: "Low" });
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("refuses an unanswered required field, declaring nothing", async () => {
|
|
867
|
+
storedForm = buildForm({ customFieldSettings: FORM_QUESTIONS });
|
|
868
|
+
|
|
869
|
+
const error: Exception | undefined = await refusal(
|
|
870
|
+
submit({ answers: { ...VALID_ANSWERS, customFields: { Region: "EU" } } }),
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
874
|
+
expect(error?.message).toBe("Impact is required.");
|
|
875
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("refuses an answer that is not one of a dropdown's options", async () => {
|
|
879
|
+
storedForm = buildForm({ customFieldSettings: FORM_QUESTIONS });
|
|
880
|
+
|
|
881
|
+
const error: Exception | undefined = await refusal(
|
|
882
|
+
submit({
|
|
883
|
+
answers: { ...VALID_ANSWERS, customFields: { Impact: "Apocalyptic" } },
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
888
|
+
expect(error?.message).toContain("Apocalyptic");
|
|
889
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("a required yes/no question must be ticked", async () => {
|
|
893
|
+
storedForm = buildForm({
|
|
894
|
+
customFieldSettings: { acknowledged: "Required" },
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
const error: Exception | undefined = await refusal(
|
|
898
|
+
submit({
|
|
899
|
+
answers: { ...VALID_ANSWERS, customFields: { Acknowledged: false } },
|
|
900
|
+
}),
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
expect(error?.message).toBe("Acknowledged must be checked.");
|
|
904
|
+
|
|
905
|
+
await submit({
|
|
906
|
+
answers: { ...VALID_ANSWERS, customFields: { Acknowledged: "true" } },
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
expect(createCall().data.customFields).toEqual({ Acknowledged: true });
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
/*
|
|
913
|
+
* One anonymous request of a million multi-select entries must not cost
|
|
914
|
+
* the shared API process seconds of work, nor come back (and be logged)
|
|
915
|
+
* as a refusal the size of the request.
|
|
916
|
+
*/
|
|
917
|
+
test("refuses a multi-select answer of a million entries at once, with one short message, declaring nothing", async () => {
|
|
918
|
+
storedForm = buildForm({ customFieldSettings: { systems: "Optional" } });
|
|
919
|
+
|
|
920
|
+
const started: number = Date.now();
|
|
921
|
+
|
|
922
|
+
const error: Exception | undefined = await refusal(
|
|
923
|
+
submit({
|
|
924
|
+
answers: {
|
|
925
|
+
...VALID_ANSWERS,
|
|
926
|
+
customFields: {
|
|
927
|
+
Systems: Array.from(
|
|
928
|
+
{ length: 1_000_000 },
|
|
929
|
+
(_value: unknown, index: number): string => {
|
|
930
|
+
return `x${index}`;
|
|
931
|
+
},
|
|
932
|
+
),
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
}),
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
expect(Date.now() - started).toBeLessThan(2000);
|
|
939
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
940
|
+
expect(error?.message).toBe("Systems cannot have more than 100 choices.");
|
|
941
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
/*
|
|
945
|
+
* Nothing the form's page sends is an object. Checked as an API write
|
|
946
|
+
* would check it, an object would be turned into text twice before it
|
|
947
|
+
* was refused - seconds of the server's time for one with a million
|
|
948
|
+
* keys - so it is refused before anything reads it.
|
|
949
|
+
*/
|
|
950
|
+
test("refuses an object answer before reading it, with one short message, declaring nothing", async () => {
|
|
951
|
+
storedForm = buildForm({ customFieldSettings: FORM_QUESTIONS });
|
|
952
|
+
|
|
953
|
+
const answer: Record<string, string> = {};
|
|
954
|
+
|
|
955
|
+
for (let index: number = 0; index < 1_000_000; index++) {
|
|
956
|
+
answer[`k${index}`] = "EU";
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const started: number = Date.now();
|
|
960
|
+
|
|
961
|
+
const error: Exception | undefined = await refusal(
|
|
962
|
+
submit({
|
|
963
|
+
answers: {
|
|
964
|
+
...VALID_ANSWERS,
|
|
965
|
+
customFields: { Impact: "High", Region: answer },
|
|
966
|
+
},
|
|
967
|
+
}),
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
expect(Date.now() - started).toBeLessThan(2000);
|
|
971
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
972
|
+
expect(error?.message).toBe("Region takes one answer, not an object.");
|
|
973
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
974
|
+
expect(reserveFormSubmission).not.toHaveBeenCalled();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
/*
|
|
978
|
+
* A field mapped from a monitor field takes the monitor's value once the
|
|
979
|
+
* incident has a monitor - and a form's incident gets its template's
|
|
980
|
+
* monitors. Such a question is not asked then, so it cannot be required,
|
|
981
|
+
* and an answer sent anyway is dropped like any the form does not ask.
|
|
982
|
+
*/
|
|
983
|
+
describe("a field copied from a monitor", () => {
|
|
984
|
+
const VENDOR: IncidentCustomField = (() => {
|
|
985
|
+
const field: IncidentCustomField = customField({
|
|
986
|
+
name: "Vendor",
|
|
987
|
+
variableKey: "vendor",
|
|
988
|
+
customFieldType: CustomFieldType.Text,
|
|
989
|
+
sortOrder: 8,
|
|
990
|
+
});
|
|
991
|
+
field.mapFromResourceType = CustomFieldMappingSourceResource.Monitor;
|
|
992
|
+
field.mapFromCustomFieldName = "Vendor";
|
|
993
|
+
return field;
|
|
994
|
+
})();
|
|
995
|
+
|
|
996
|
+
function templateWithMonitor(): IncidentTemplate {
|
|
997
|
+
const template: IncidentTemplate =
|
|
998
|
+
templateWithSeverity(MINOR_SEVERITY_ID);
|
|
999
|
+
const monitor: Monitor = new Monitor();
|
|
1000
|
+
monitor._id = "e1000000-0000-4000-8000-0000000000f1";
|
|
1001
|
+
template.monitors = [monitor];
|
|
1002
|
+
return template;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
beforeEach(() => {
|
|
1006
|
+
customFieldFindBy.mockResolvedValue([...PROJECT_FIELDS, VENDOR] as never);
|
|
1007
|
+
templateFindOneBy.mockResolvedValue(templateWithMonitor() as never);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("is not required when the form's template attaches a monitor, and an answer to it is dropped", async () => {
|
|
1011
|
+
storedForm = withTemplate({
|
|
1012
|
+
customFieldSettings: { vendor: "Required", region: "Optional" },
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
await submit({
|
|
1016
|
+
answers: {
|
|
1017
|
+
...VALID_ANSWERS,
|
|
1018
|
+
customFields: { Vendor: "Azure", Region: "EU" },
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
expect(createCall().data.customFields).toEqual({ Region: "EU" });
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("is still required, and kept, on a form without a template", async () => {
|
|
1026
|
+
storedForm = buildForm({
|
|
1027
|
+
customFieldSettings: { vendor: "Required" },
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const error: Exception | undefined = await refusal(
|
|
1031
|
+
submit({ answers: { ...VALID_ANSWERS, customFields: {} } }),
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
expect(error?.message).toBe("Vendor is required.");
|
|
1035
|
+
|
|
1036
|
+
await submit({
|
|
1037
|
+
answers: { ...VALID_ANSWERS, customFields: { Vendor: "Azure" } },
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
expect(createCall().data.customFields).toEqual({ Vendor: "Azure" });
|
|
1041
|
+
expect(templateFindOneBy).not.toHaveBeenCalled();
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
test("ignores custom field answers entirely, and reads no fields, when the form asks none", async () => {
|
|
1046
|
+
await submit({
|
|
1047
|
+
answers: {
|
|
1048
|
+
...VALID_ANSWERS,
|
|
1049
|
+
customFields: { Impact: "High", Region: "EU" },
|
|
1050
|
+
},
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
expect(createCall().data.customFields).toEqual({});
|
|
1054
|
+
expect(customFieldFindBy).not.toHaveBeenCalled();
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
/*
|
|
1059
|
+
* A stranger's report is posted to the project's Slack and Teams channels
|
|
1060
|
+
* and rendered for every responder and in owners' emails, and nobody reads
|
|
1061
|
+
* it over first. What the incident stores must not act on its own there:
|
|
1062
|
+
* no Slack mention, no image fetched from the reporter's server, no mermaid
|
|
1063
|
+
* diagram run - while reading exactly as the reporter typed it.
|
|
1064
|
+
*/
|
|
1065
|
+
describe("IncidentFormService.submitPublicForm - nothing the reporter wrote acts on its own", () => {
|
|
1066
|
+
const WORD_JOINER: string = "\u2060";
|
|
1067
|
+
|
|
1068
|
+
function withoutJoiners(text: string | undefined): string {
|
|
1069
|
+
return (text || "").split(WORD_JOINER).join("");
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function lexedTokens(markdown: string): Array<Token> {
|
|
1073
|
+
const tokens: Array<Token> = [];
|
|
1074
|
+
|
|
1075
|
+
marked.walkTokens(marked.lexer(markdown), (token: Token): void => {
|
|
1076
|
+
tokens.push(token);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
return tokens;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function expectNothingFetchedOrRun(markdown: string): void {
|
|
1083
|
+
const tokens: Array<Token> = lexedTokens(markdown);
|
|
1084
|
+
|
|
1085
|
+
expect(
|
|
1086
|
+
tokens.filter((token: Token): boolean => {
|
|
1087
|
+
return token.type === "image";
|
|
1088
|
+
}),
|
|
1089
|
+
).toEqual([]);
|
|
1090
|
+
expect(
|
|
1091
|
+
tokens.filter((token: Token): boolean => {
|
|
1092
|
+
return (
|
|
1093
|
+
token.type === "code" &&
|
|
1094
|
+
((token as Tokens.Code).lang || "")
|
|
1095
|
+
.toLowerCase()
|
|
1096
|
+
.startsWith("mermaid")
|
|
1097
|
+
);
|
|
1098
|
+
}),
|
|
1099
|
+
).toEqual([]);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function slackText(markdown: string): string {
|
|
1103
|
+
return JSON.stringify(
|
|
1104
|
+
SlackUtil.getMarkdownBlocks({
|
|
1105
|
+
payloadMarkdownBlock: {
|
|
1106
|
+
_type: "WorkspacePayloadMarkdown",
|
|
1107
|
+
text: markdown,
|
|
1108
|
+
},
|
|
1109
|
+
}),
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const LIVE_SLACK_SEQUENCE: RegExp = /<(![a-z]|@[A-Z0-9]|#C)/;
|
|
1114
|
+
|
|
1115
|
+
const HOSTILE_DESCRIPTION: string =
|
|
1116
|
+
"<!here> every order fails <@U0123ABC>\n\n" +
|
|
1117
|
+
"\n\n" +
|
|
1118
|
+
"See ![shot][r]\n\n" +
|
|
1119
|
+
"[r]: https://tracker.example/q.png\n\n" +
|
|
1120
|
+
"```mermaid\ngraph TD\n A-->B\n```\n\n" +
|
|
1121
|
+
"[the checkout page](https://shop.example/checkout)";
|
|
1122
|
+
|
|
1123
|
+
test("the title and description keep their text, lose their mentions, images and diagrams", async () => {
|
|
1124
|
+
await submit({
|
|
1125
|
+
answers: {
|
|
1126
|
+
...VALID_ANSWERS,
|
|
1127
|
+
title: "<!channel> Checkout is down",
|
|
1128
|
+
description: HOSTILE_DESCRIPTION,
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
const created: Incident = createCall().data;
|
|
1133
|
+
|
|
1134
|
+
expect(created.title).not.toMatch(/<[!@#]/);
|
|
1135
|
+
expect(withoutJoiners(created.title)).toBe("<!channel> Checkout is down");
|
|
1136
|
+
|
|
1137
|
+
expect(created.description).not.toMatch(/<[!@#]/);
|
|
1138
|
+
expectNothingFetchedOrRun(created.description || "");
|
|
1139
|
+
expect(slackText(created.description || "")).not.toMatch(
|
|
1140
|
+
LIVE_SLACK_SEQUENCE,
|
|
1141
|
+
);
|
|
1142
|
+
|
|
1143
|
+
// The reporter's link to the broken page still works.
|
|
1144
|
+
expect(
|
|
1145
|
+
lexedTokens(created.description || "")
|
|
1146
|
+
.filter((token: Token): boolean => {
|
|
1147
|
+
return token.type === "link";
|
|
1148
|
+
})
|
|
1149
|
+
.map((token: Token): string => {
|
|
1150
|
+
return (token as Tokens.Link).href;
|
|
1151
|
+
}),
|
|
1152
|
+
).toEqual([
|
|
1153
|
+
"https://tracker.example/p.png",
|
|
1154
|
+
"https://tracker.example/q.png",
|
|
1155
|
+
"https://shop.example/checkout",
|
|
1156
|
+
]);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
test("the unneutralised description really would have fetched, run and mentioned", () => {
|
|
1160
|
+
const tokens: Array<Token> = lexedTokens(HOSTILE_DESCRIPTION);
|
|
1161
|
+
|
|
1162
|
+
expect(
|
|
1163
|
+
tokens.filter((token: Token): boolean => {
|
|
1164
|
+
return token.type === "image";
|
|
1165
|
+
}),
|
|
1166
|
+
).toHaveLength(2);
|
|
1167
|
+
expect(slackText(HOSTILE_DESCRIPTION)).toMatch(LIVE_SLACK_SEQUENCE);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
/*
|
|
1171
|
+
* The dashboard reads a description with another parser than marked
|
|
1172
|
+
* (remark, with footnotes), and shows an image in each of these where
|
|
1173
|
+
* marked sees code or a link definition. What is stored must hold no
|
|
1174
|
+
* image syntax left open, whichever reading is right.
|
|
1175
|
+
*/
|
|
1176
|
+
test("a description the renderers read differently still stores no image", async () => {
|
|
1177
|
+
const description: string =
|
|
1178
|
+
"[a]: https://x.example\n \n\n" +
|
|
1179
|
+
"See[^1]\n\n[^1]: ";
|
|
1180
|
+
|
|
1181
|
+
// marked finds no image in it: the dashboard would have shown two.
|
|
1182
|
+
expect(
|
|
1183
|
+
lexedTokens(description).filter((token: Token): boolean => {
|
|
1184
|
+
return token.type === "image";
|
|
1185
|
+
}),
|
|
1186
|
+
).toEqual([]);
|
|
1187
|
+
|
|
1188
|
+
await submit({
|
|
1189
|
+
answers: { ...VALID_ANSWERS, description: description },
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
const stored: string = createCall().data.description || "";
|
|
1193
|
+
|
|
1194
|
+
expect(stored).not.toMatch(/(^|[^\\])(\\\\)*!\[/);
|
|
1195
|
+
expect(withoutJoiners(stored).replace(/\\!\[/g, " <!here>",
|
|
1213
|
+
Notes: "Ping <#C0123ABC> and <@U0123ABC>",
|
|
1214
|
+
Region: "<!everyone> EU",
|
|
1215
|
+
Impact: "High",
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
const customFields: JSONObject = createCall().data.customFields || {};
|
|
1221
|
+
|
|
1222
|
+
expectNothingFetchedOrRun(String(customFields["Details"]));
|
|
1223
|
+
expect(String(customFields["Details"])).not.toMatch(/<[!@#]/);
|
|
1224
|
+
expect(customFields["Notes"]).not.toMatch(/<[!@#]/);
|
|
1225
|
+
expect(withoutJoiners(String(customFields["Notes"]))).toBe(
|
|
1226
|
+
"Ping <#C0123ABC> and <@U0123ABC>",
|
|
1227
|
+
);
|
|
1228
|
+
expect(withoutJoiners(String(customFields["Region"]))).toBe(
|
|
1229
|
+
"<!everyone> EU",
|
|
1230
|
+
);
|
|
1231
|
+
expect(customFields["Region"]).not.toMatch(/<[!@#]/);
|
|
1232
|
+
expect(customFields["Impact"]).toBe("High");
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test("ordinary answers are stored exactly as typed", async () => {
|
|
1236
|
+
storedForm = buildForm({ customFieldSettings: FORM_QUESTIONS });
|
|
1237
|
+
|
|
1238
|
+
await submit({
|
|
1239
|
+
answers: {
|
|
1240
|
+
...VALID_ANSWERS,
|
|
1241
|
+
description: "Checkout fails at **step 3**:\n\n```\nError 502\n```",
|
|
1242
|
+
customFields: { Impact: "High", Region: "EU-West (a < b)" },
|
|
1243
|
+
},
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
expect(createCall().data.title).toBe("Checkout is down");
|
|
1247
|
+
expect(createCall().data.description).toBe(
|
|
1248
|
+
"Checkout fails at **step 3**:\n\n```\nError 502\n```",
|
|
1249
|
+
);
|
|
1250
|
+
expect(createCall().data.customFields).toEqual({
|
|
1251
|
+
Impact: "High",
|
|
1252
|
+
Region: "EU-West (a < b)",
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
/*
|
|
1257
|
+
* Breaking a sequence adds an invisible character, and Incident.title is
|
|
1258
|
+
* a varchar(500): a title the validator accepted can outgrow it.
|
|
1259
|
+
*/
|
|
1260
|
+
test("refuses a title that no longer fits the incident once its mentions are broken, declaring nothing", async () => {
|
|
1261
|
+
const title: string = "<!".repeat(250);
|
|
1262
|
+
|
|
1263
|
+
const error: Exception | undefined = await refusal(
|
|
1264
|
+
submit({ answers: { ...VALID_ANSWERS, title: title } }),
|
|
1265
|
+
);
|
|
1266
|
+
|
|
1267
|
+
expect(title.length).toBe(500);
|
|
1268
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
1269
|
+
expect(error?.message).toBe(INCIDENT_FORM_TITLE_TOO_LONG_MESSAGE);
|
|
1270
|
+
expect(error?.message).toBe("Title cannot be more than 500 characters.");
|
|
1271
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
test("refuses a title that no longer fits once its image syntax is broken, declaring nothing", async () => {
|
|
1275
|
+
const title: string = " Checkout is down";
|
|
1296
|
+
|
|
1297
|
+
await submit({ answers: { ...VALID_ANSWERS, title: typed } });
|
|
1298
|
+
|
|
1299
|
+
const title: string = createCall().data.title || "";
|
|
1300
|
+
|
|
1301
|
+
expect(title).not.toMatch(/!\[|<[!@#]/);
|
|
1302
|
+
expect(withoutJoiners(title)).toBe(typed);
|
|
1303
|
+
|
|
1304
|
+
const quoted: Array<string> = [
|
|
1305
|
+
`#### Episode #1 Created\n\n**${title.substring(0, 50)}**\n\n`,
|
|
1306
|
+
`**Incident INC-42** added to episode: ${title}`,
|
|
1307
|
+
`Added to **Episode #1**: ${title.substring(0, 50)}`,
|
|
1308
|
+
];
|
|
1309
|
+
|
|
1310
|
+
for (const markdown of quoted) {
|
|
1311
|
+
expectNothingFetchedOrRun(markdown);
|
|
1312
|
+
expect(slackText(markdown)).not.toMatch(LIVE_SLACK_SEQUENCE);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
for (const html of renderAsDashboard(quoted)) {
|
|
1316
|
+
expect(html).not.toContain("<img");
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Unbroken, the same shapes really do show the image.
|
|
1320
|
+
expect(
|
|
1321
|
+
lexedTokens(`**Incident INC-42** added to episode: ${typed}`).filter(
|
|
1322
|
+
(token: Token): boolean => {
|
|
1323
|
+
return token.type === "image";
|
|
1324
|
+
},
|
|
1325
|
+
),
|
|
1326
|
+
).toHaveLength(1);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
test("the submission record keeps the reporter's name as typed", async () => {
|
|
1330
|
+
await submit({
|
|
1331
|
+
answers: { ...VALID_ANSWERS, reporterName: "<@U0123ABC>" },
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
const createBy: { data: IncidentFormSubmission } = submissionCreate.mock
|
|
1335
|
+
.calls[0]![0] as never;
|
|
1336
|
+
|
|
1337
|
+
expect(createBy.data.reporterName).toBe("<@U0123ABC>");
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
test("the private note mentions nobody in Slack, whatever the names say", async () => {
|
|
1341
|
+
storedForm = buildForm({ name: "<!channel> Reports" });
|
|
1342
|
+
|
|
1343
|
+
await submit({
|
|
1344
|
+
answers: {
|
|
1345
|
+
...VALID_ANSWERS,
|
|
1346
|
+
reporterName: "<@U0123ABC> <!here>",
|
|
1347
|
+
reporterEmail: "#ops@corp.example",
|
|
1348
|
+
},
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
const note: string = (
|
|
1352
|
+
noteCreate.mock.calls[0]![0] as { data: IncidentInternalNote }
|
|
1353
|
+
).data.note!;
|
|
1354
|
+
|
|
1355
|
+
expect(note).not.toMatch(/<[!@#]/);
|
|
1356
|
+
expect(slackText(note)).not.toMatch(LIVE_SLACK_SEQUENCE);
|
|
1357
|
+
// The "#" escaped in the link, where it would begin a fragment.
|
|
1358
|
+
expect(slackText(note)).toContain(
|
|
1359
|
+
"<mailto:%23ops@corp.example|#ops@corp.example>",
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
const html: string = await Markdown.convertToHTML(
|
|
1363
|
+
note,
|
|
1364
|
+
MarkdownContentType.Email,
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
expect(html).toContain('href="mailto:%23ops@corp.example"');
|
|
1368
|
+
});
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
describe("neutralizeIncidentFormReport", () => {
|
|
1372
|
+
test("leaves the reporter's name and address, and an answer to a field it is not told about, alone", () => {
|
|
1373
|
+
const report: ValidatedIncidentFormSubmission =
|
|
1374
|
+
neutralizeIncidentFormReport({
|
|
1375
|
+
answers: {
|
|
1376
|
+
title: "<!here>",
|
|
1377
|
+
reporterName: "<@U1>",
|
|
1378
|
+
reporterEmail: "jane@example.com",
|
|
1379
|
+
customFields: { Unlisted: "<!channel>" },
|
|
1380
|
+
},
|
|
1381
|
+
askedDefinitions: [],
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
expect(report.title).toBe("<\u2060!here>");
|
|
1385
|
+
expect(report.reporterName).toBe("<@U1>");
|
|
1386
|
+
expect(report.reporterEmail).toBe("jane@example.com");
|
|
1387
|
+
expect(report.customFields).toEqual({ Unlisted: "<!channel>" });
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
test("treats a field of a type this version does not know as text, as the form asks it", () => {
|
|
1391
|
+
const report: ValidatedIncidentFormSubmission =
|
|
1392
|
+
neutralizeIncidentFormReport({
|
|
1393
|
+
answers: {
|
|
1394
|
+
title: "Down",
|
|
1395
|
+
customFields: { Legacy: "<!channel>", Count: "3" },
|
|
1396
|
+
},
|
|
1397
|
+
askedDefinitions: [
|
|
1398
|
+
{ name: "Legacy", customFieldType: null },
|
|
1399
|
+
{ name: "Count", customFieldType: CustomFieldType.Number },
|
|
1400
|
+
],
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
expect(report.customFields).toEqual({
|
|
1404
|
+
Legacy: "<\u2060!channel>",
|
|
1405
|
+
Count: "3",
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
test("never mutates the validated answers", () => {
|
|
1410
|
+
const answers: ValidatedIncidentFormSubmission = {
|
|
1411
|
+
title: "<!here>",
|
|
1412
|
+
description: "",
|
|
1413
|
+
customFields: { Notes: "<@U1>" },
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
neutralizeIncidentFormReport({
|
|
1417
|
+
answers: answers,
|
|
1418
|
+
askedDefinitions: [
|
|
1419
|
+
{ name: "Notes", customFieldType: CustomFieldType.LongText },
|
|
1420
|
+
],
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
expect(answers).toEqual({
|
|
1424
|
+
title: "<!here>",
|
|
1425
|
+
description: "",
|
|
1426
|
+
customFields: { Notes: "<@U1>" },
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
test("stores an answer to a field named __proto__ as an answer", () => {
|
|
1431
|
+
const customFields: JSONObject = {};
|
|
1432
|
+
Object.defineProperty(customFields, "__proto__", {
|
|
1433
|
+
value: "<!here>",
|
|
1434
|
+
enumerable: true,
|
|
1435
|
+
writable: true,
|
|
1436
|
+
configurable: true,
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
const report: ValidatedIncidentFormSubmission =
|
|
1440
|
+
neutralizeIncidentFormReport({
|
|
1441
|
+
answers: { title: "Down", customFields: customFields },
|
|
1442
|
+
askedDefinitions: [
|
|
1443
|
+
{ name: "__proto__", customFieldType: CustomFieldType.Text },
|
|
1444
|
+
],
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
expect(Object.getPrototypeOf(report.customFields)).toBe(Object.prototype);
|
|
1448
|
+
expect(
|
|
1449
|
+
Object.getOwnPropertyDescriptor(report.customFields, "__proto__")?.value,
|
|
1450
|
+
).toBe("<\u2060!here>");
|
|
1451
|
+
});
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
describe("IncidentFormService.submitPublicForm - the reporter", () => {
|
|
1455
|
+
test.each([
|
|
1456
|
+
["no name", { reporterName: undefined }, "Your Name is required."],
|
|
1457
|
+
["no email", { reporterEmail: undefined }, "Your Email is required."],
|
|
1458
|
+
[
|
|
1459
|
+
"an address that is not one",
|
|
1460
|
+
{ reporterEmail: "jane at example" },
|
|
1461
|
+
"Your Email is not a valid email address.",
|
|
1462
|
+
],
|
|
1463
|
+
])(
|
|
1464
|
+
"refuses %s when the form requires the reporter's details",
|
|
1465
|
+
async (
|
|
1466
|
+
_label: string,
|
|
1467
|
+
change: Record<string, string | undefined>,
|
|
1468
|
+
message: string,
|
|
1469
|
+
) => {
|
|
1470
|
+
const answers: JSONObject = { ...VALID_ANSWERS };
|
|
1471
|
+
|
|
1472
|
+
for (const [key, value] of Object.entries(change)) {
|
|
1473
|
+
if (value === undefined) {
|
|
1474
|
+
delete answers[key];
|
|
1475
|
+
} else {
|
|
1476
|
+
answers[key] = value;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const error: Exception | undefined = await refusal(submit({ answers }));
|
|
1481
|
+
|
|
1482
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
1483
|
+
expect(error?.message).toBe(message);
|
|
1484
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
1485
|
+
},
|
|
1486
|
+
);
|
|
1487
|
+
|
|
1488
|
+
test("reports every problem at once, as one message", async () => {
|
|
1489
|
+
const error: Exception | undefined = await refusal(
|
|
1490
|
+
submit({ answers: { title: "", reporterEmail: "nope" } }),
|
|
1491
|
+
);
|
|
1492
|
+
|
|
1493
|
+
expect(error?.message).toBe(
|
|
1494
|
+
formatIncidentFormSubmissionErrors([
|
|
1495
|
+
"Title is required.",
|
|
1496
|
+
"Your Name is required.",
|
|
1497
|
+
"Your Email is not a valid email address.",
|
|
1498
|
+
]),
|
|
1499
|
+
);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
test("refuses a body without answers", async () => {
|
|
1503
|
+
const error: Exception | undefined = await refusal(
|
|
1504
|
+
submit({ answers: undefined }),
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
1508
|
+
expect(error?.message).toBe(
|
|
1509
|
+
"The submission must be an object holding the form's answers.",
|
|
1510
|
+
);
|
|
1511
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
/*
|
|
1516
|
+
* The template's owners go with the create, for IncidentService to add once
|
|
1517
|
+
* the incident's Slack and Teams channels exist - so their own hooks invite
|
|
1518
|
+
* them to those channels - and to notify. Added by the form after the
|
|
1519
|
+
* create returned, they raced the channel's creation and were never
|
|
1520
|
+
* invited (IncidentTemplateOwnersOnDeclare.test.ts pins the other half).
|
|
1521
|
+
*/
|
|
1522
|
+
describe("IncidentFormService.submitPublicForm - the template's owners", () => {
|
|
1523
|
+
function ids(value: unknown): Array<string> {
|
|
1524
|
+
return (value as Array<ObjectID>).map((id: ObjectID): string => {
|
|
1525
|
+
return id.toString();
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
test("are handed to the create, to be added once the incident's channels exist, and notified", async () => {
|
|
1530
|
+
storedForm = withTemplate();
|
|
1531
|
+
|
|
1532
|
+
await submit();
|
|
1533
|
+
|
|
1534
|
+
const miscDataProps: JSONObject = createCall().miscDataProps!;
|
|
1535
|
+
|
|
1536
|
+
expect(Object.keys(miscDataProps).sort()).toEqual(
|
|
1537
|
+
["notifyOwners", "ownerTeams", "ownerUsers"].sort(),
|
|
1538
|
+
);
|
|
1539
|
+
expect(ids(miscDataProps["ownerUsers"])).toEqual(OWNER_USER_IDS);
|
|
1540
|
+
expect(ids(miscDataProps["ownerTeams"])).toEqual([OWNER_TEAM_ID]);
|
|
1541
|
+
expect(miscDataProps["notifyOwners"]).toBe(true);
|
|
1542
|
+
expect(createCall().props).toEqual({ isRoot: true });
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test("are never added by the form itself, after the create returned", async () => {
|
|
1546
|
+
storedForm = withTemplate();
|
|
1547
|
+
|
|
1548
|
+
await submit();
|
|
1549
|
+
|
|
1550
|
+
expect(addOwners).not.toHaveBeenCalled();
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
test("are read for the form's template in the form's project, as root", async () => {
|
|
1554
|
+
storedForm = withTemplate();
|
|
1555
|
+
|
|
1556
|
+
await submit();
|
|
1557
|
+
|
|
1558
|
+
for (const findBy of [
|
|
1559
|
+
ownerUserFindBy.mock.calls[0]![0],
|
|
1560
|
+
ownerTeamFindBy.mock.calls[0]![0],
|
|
1561
|
+
] as Array<{
|
|
1562
|
+
query: Record<string, unknown>;
|
|
1563
|
+
props: Record<string, unknown>;
|
|
1564
|
+
}>) {
|
|
1565
|
+
expect(String(findBy.query["incidentTemplateId"])).toBe(TEMPLATE_ID);
|
|
1566
|
+
expect(String(findBy.query["projectId"])).toBe(PROJECT_ID.toString());
|
|
1567
|
+
expect(findBy.props).toEqual({ isRoot: true });
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
test("with only owner users, hands them and no teams", async () => {
|
|
1572
|
+
storedForm = withTemplate();
|
|
1573
|
+
ownerTeamFindBy.mockResolvedValue([] as never);
|
|
1574
|
+
|
|
1575
|
+
await submit();
|
|
1576
|
+
|
|
1577
|
+
expect(createCall().miscDataProps).toEqual({
|
|
1578
|
+
ownerUsers: expect.any(Array),
|
|
1579
|
+
notifyOwners: true,
|
|
1580
|
+
});
|
|
1581
|
+
expect(ids(createCall().miscDataProps!["ownerUsers"])).toEqual(
|
|
1582
|
+
OWNER_USER_IDS,
|
|
1583
|
+
);
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
test("hands nothing when the template has no owners", async () => {
|
|
1587
|
+
storedForm = withTemplate();
|
|
1588
|
+
ownerUserFindBy.mockResolvedValue([] as never);
|
|
1589
|
+
ownerTeamFindBy.mockResolvedValue([] as never);
|
|
1590
|
+
|
|
1591
|
+
await submit();
|
|
1592
|
+
|
|
1593
|
+
expect(createCall()).not.toHaveProperty("miscDataProps");
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
test("reads no owners, and hands none, for a form without a template", async () => {
|
|
1597
|
+
await submit();
|
|
1598
|
+
|
|
1599
|
+
expect(ownerUserFindBy).not.toHaveBeenCalled();
|
|
1600
|
+
expect(ownerTeamFindBy).not.toHaveBeenCalled();
|
|
1601
|
+
expect(createCall()).not.toHaveProperty("miscDataProps");
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
test("a failure reading them is logged, and the incident is declared without them", async () => {
|
|
1605
|
+
storedForm = withTemplate();
|
|
1606
|
+
ownerTeamFindBy.mockRejectedValue(new Error("read failed"));
|
|
1607
|
+
|
|
1608
|
+
await expect(submit()).resolves.toEqual({
|
|
1609
|
+
incidentNumber: "INC-42",
|
|
1610
|
+
successMessage: "Thanks - **we are on it**.",
|
|
1611
|
+
});
|
|
1612
|
+
expect(createCall()).not.toHaveProperty("miscDataProps");
|
|
1613
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
1614
|
+
expect.stringContaining(
|
|
1615
|
+
"could not read the owners of the template of incident form",
|
|
1616
|
+
),
|
|
1617
|
+
expect.objectContaining({ incidentFormId: FORM_ID }),
|
|
1618
|
+
);
|
|
1619
|
+
expect(noteCreate).toHaveBeenCalledTimes(1);
|
|
1620
|
+
});
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
describe("IncidentFormService.submitPublicForm - the submission record", () => {
|
|
1624
|
+
test("names the form, the incident and the reporter, with the email lowercased", async () => {
|
|
1625
|
+
await submit();
|
|
1626
|
+
|
|
1627
|
+
expect(submissionCreate).toHaveBeenCalledTimes(1);
|
|
1628
|
+
|
|
1629
|
+
const createBy: {
|
|
1630
|
+
data: IncidentFormSubmission;
|
|
1631
|
+
props: Record<string, unknown>;
|
|
1632
|
+
} = submissionCreate.mock.calls[0]![0] as never;
|
|
1633
|
+
|
|
1634
|
+
expect(createBy.props).toEqual({ isRoot: true });
|
|
1635
|
+
expect(createBy.data).toBeInstanceOf(IncidentFormSubmission);
|
|
1636
|
+
expect(createBy.data.projectId?.toString()).toBe(PROJECT_ID.toString());
|
|
1637
|
+
expect(createBy.data.incidentFormId?.toString()).toBe(FORM_ID);
|
|
1638
|
+
expect(createBy.data.incidentId?.toString()).toBe(INCIDENT_ID);
|
|
1639
|
+
expect(createBy.data.reporterName).toBe("Jane Doe");
|
|
1640
|
+
expect(createBy.data.reporterEmail).toBeInstanceOf(Email);
|
|
1641
|
+
expect(createBy.data.reporterEmail?.toString()).toBe(
|
|
1642
|
+
"jane.doe@example.com",
|
|
1643
|
+
);
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
test("has no reporter for an anonymous report", async () => {
|
|
1647
|
+
storedForm = buildForm({ isReporterDetailsRequired: false });
|
|
1648
|
+
|
|
1649
|
+
await submit({ answers: { title: "Checkout is down" } });
|
|
1650
|
+
|
|
1651
|
+
const createBy: { data: IncidentFormSubmission } = submissionCreate.mock
|
|
1652
|
+
.calls[0]![0] as never;
|
|
1653
|
+
|
|
1654
|
+
expect(createBy.data.reporterName).toBeUndefined();
|
|
1655
|
+
expect(createBy.data.reporterEmail).toBeUndefined();
|
|
1656
|
+
expect(createBy.data.incidentId?.toString()).toBe(INCIDENT_ID);
|
|
1657
|
+
});
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
describe("IncidentFormService.submitPublicForm - the private note", () => {
|
|
1661
|
+
function noteCall(): {
|
|
1662
|
+
data: IncidentInternalNote;
|
|
1663
|
+
props: Record<string, unknown>;
|
|
1664
|
+
} {
|
|
1665
|
+
expect(noteCreate).toHaveBeenCalledTimes(1);
|
|
1666
|
+
|
|
1667
|
+
return noteCreate.mock.calls[0]![0] as never;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
test("is a private note on the incident, written as root", async () => {
|
|
1671
|
+
await submit();
|
|
1672
|
+
|
|
1673
|
+
expect(noteCall().props).toEqual({ isRoot: true });
|
|
1674
|
+
expect(noteCall().data).toBeInstanceOf(IncidentInternalNote);
|
|
1675
|
+
expect(noteCall().data.projectId?.toString()).toBe(PROJECT_ID.toString());
|
|
1676
|
+
expect(noteCall().data.incidentId?.toString()).toBe(INCIDENT_ID);
|
|
1677
|
+
expect(noteCall().data.createdByUserId).toBeUndefined();
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
test("names the form and the reporter", async () => {
|
|
1681
|
+
await submit();
|
|
1682
|
+
|
|
1683
|
+
expect(noteCall().data.note).toBe(
|
|
1684
|
+
"Reported through the incident form **Report a Problem** by Jane Doe (<jane.doe@example.com>).",
|
|
1685
|
+
);
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test("says anonymously when the reporter gave no details", async () => {
|
|
1689
|
+
storedForm = buildForm({ isReporterDetailsRequired: false });
|
|
1690
|
+
|
|
1691
|
+
await submit({ answers: { title: "Checkout is down" } });
|
|
1692
|
+
|
|
1693
|
+
expect(noteCall().data.note).toBe(
|
|
1694
|
+
"Reported anonymously through the incident form **Report a Problem**.",
|
|
1695
|
+
);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
/*
|
|
1699
|
+
* The note is posted as it is - nobody reads it over first - so a
|
|
1700
|
+
* reporter's name must arrive as the characters they typed: never a link
|
|
1701
|
+
* to click, never an image fetched when a responder opens the incident,
|
|
1702
|
+
* never restyling the sentence.
|
|
1703
|
+
*/
|
|
1704
|
+
test("escapes a hostile name and email so they read as typed", async () => {
|
|
1705
|
+
storedForm = buildForm({ isReporterDetailsRequired: false });
|
|
1706
|
+
|
|
1707
|
+
await submit({
|
|
1708
|
+
answers: {
|
|
1709
|
+
title: "Checkout is down",
|
|
1710
|
+
reporterName:
|
|
1711
|
+
"[x](javascript:alert(1)) **bold**  <img>",
|
|
1712
|
+
reporterEmail: "a_b+c@example.com",
|
|
1713
|
+
},
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
const note: string = noteCall().data.note || "";
|
|
1717
|
+
|
|
1718
|
+
expect(note).toBe(
|
|
1719
|
+
"Reported through the incident form **Report a Problem** by " +
|
|
1720
|
+
"\\[x\\]\\(javascript:alert\\(1\\)\\) \\*\\*bold\\*\\* \\!\\[p\\]\\(https://t.example/p\\) \\<img\\> " +
|
|
1721
|
+
"(<a_b+c@example.com>).",
|
|
1722
|
+
);
|
|
1723
|
+
expect(note).not.toContain("[x](");
|
|
1724
|
+
expect(note).not.toContain("**bold**");
|
|
1725
|
+
expect(note).not.toContain(";
|
|
1726
|
+
});
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
describe("getIncidentFormReporterNote", () => {
|
|
1730
|
+
test.each([
|
|
1731
|
+
[
|
|
1732
|
+
"a name and an email",
|
|
1733
|
+
{ reporterName: "Jane", reporterEmail: "jane@example.com" },
|
|
1734
|
+
"Reported through the incident form **Report a Problem** by Jane (<jane@example.com>).",
|
|
1735
|
+
],
|
|
1736
|
+
[
|
|
1737
|
+
"only a name",
|
|
1738
|
+
{ reporterName: "Jane" },
|
|
1739
|
+
"Reported through the incident form **Report a Problem** by Jane.",
|
|
1740
|
+
],
|
|
1741
|
+
[
|
|
1742
|
+
"only an email",
|
|
1743
|
+
{ reporterEmail: "jane@example.com" },
|
|
1744
|
+
"Reported through the incident form **Report a Problem** by <jane@example.com>.",
|
|
1745
|
+
],
|
|
1746
|
+
[
|
|
1747
|
+
"neither",
|
|
1748
|
+
{},
|
|
1749
|
+
"Reported anonymously through the incident form **Report a Problem**.",
|
|
1750
|
+
],
|
|
1751
|
+
[
|
|
1752
|
+
"blank details",
|
|
1753
|
+
{ reporterName: " ", reporterEmail: null },
|
|
1754
|
+
"Reported anonymously through the incident form **Report a Problem**.",
|
|
1755
|
+
],
|
|
1756
|
+
])(
|
|
1757
|
+
"with %s",
|
|
1758
|
+
(
|
|
1759
|
+
_label: string,
|
|
1760
|
+
reporter: { reporterName?: string | null; reporterEmail?: string | null },
|
|
1761
|
+
note: string,
|
|
1762
|
+
) => {
|
|
1763
|
+
expect(
|
|
1764
|
+
getIncidentFormReporterNote({
|
|
1765
|
+
formName: "Report a Problem",
|
|
1766
|
+
...reporter,
|
|
1767
|
+
}),
|
|
1768
|
+
).toBe(note);
|
|
1769
|
+
},
|
|
1770
|
+
);
|
|
1771
|
+
|
|
1772
|
+
test("escapes the form's name too, which sits inside the note's own bold", () => {
|
|
1773
|
+
expect(
|
|
1774
|
+
getIncidentFormReporterNote({ formName: "IT ** Help_desk [EU]" }),
|
|
1775
|
+
).toBe(
|
|
1776
|
+
"Reported anonymously through the incident form **IT \\*\\* Help\\_desk \\[EU\\]**.",
|
|
1777
|
+
);
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
test("reads well without a form name", () => {
|
|
1781
|
+
expect(getIncidentFormReporterNote({ reporterName: "Jane" })).toBe(
|
|
1782
|
+
"Reported through an incident form by Jane.",
|
|
1783
|
+
);
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
test("keeps a name on one line", () => {
|
|
1787
|
+
expect(
|
|
1788
|
+
getIncidentFormReporterNote({
|
|
1789
|
+
formName: "Form",
|
|
1790
|
+
reporterName: "Jane\n# Heading",
|
|
1791
|
+
}),
|
|
1792
|
+
).toBe("Reported through the incident form **Form** by Jane \\# Heading.");
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
/*
|
|
1796
|
+
* The note reaches the owners by email (SendNotePostedNotification renders
|
|
1797
|
+
* it with marked) and the incident's Slack channels (slackify-markdown).
|
|
1798
|
+
* Written backslash-escaped, the address made marked restart its link
|
|
1799
|
+
* after each escape: mary\-jane.watson@... linked mailto:jane.watson@...,
|
|
1800
|
+
* a colleague's mailbox. Every link must be the whole address the reporter
|
|
1801
|
+
* gave - hyphen, underscore, plus, apostrophe or hyphenated domain.
|
|
1802
|
+
*/
|
|
1803
|
+
describe("links the reporter's whole address", () => {
|
|
1804
|
+
const ADDRESSES: Array<string> = [
|
|
1805
|
+
"mary-jane.watson@corp.example",
|
|
1806
|
+
"first_last@company.com",
|
|
1807
|
+
"ops+alerts@company.com",
|
|
1808
|
+
"jane@my-company.com",
|
|
1809
|
+
"o'brien@company.com",
|
|
1810
|
+
"a_b+c@example.com",
|
|
1811
|
+
];
|
|
1812
|
+
|
|
1813
|
+
const REPORTERS: Array<[string, string | undefined]> = [];
|
|
1814
|
+
|
|
1815
|
+
for (const address of ADDRESSES) {
|
|
1816
|
+
REPORTERS.push([address, "Jane Doe"]);
|
|
1817
|
+
REPORTERS.push([address, undefined]);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function hrefs(html: string): Array<string> {
|
|
1821
|
+
return Array.from(html.matchAll(/href="([^"]*)"/g)).map(
|
|
1822
|
+
(match: RegExpMatchArray): string => {
|
|
1823
|
+
return match[1]!;
|
|
1824
|
+
},
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
test.each(REPORTERS)(
|
|
1829
|
+
"in the owners' email: %s (name %s)",
|
|
1830
|
+
async (address: string, reporterName: string | undefined) => {
|
|
1831
|
+
const note: string = getIncidentFormReporterNote({
|
|
1832
|
+
formName: "Report a Problem",
|
|
1833
|
+
reporterName: reporterName,
|
|
1834
|
+
reporterEmail: address,
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
const html: string = await Markdown.convertToHTML(
|
|
1838
|
+
note,
|
|
1839
|
+
MarkdownContentType.Email,
|
|
1840
|
+
);
|
|
1841
|
+
|
|
1842
|
+
expect(hrefs(html)).toEqual([
|
|
1843
|
+
`mailto:${address.replace(/'/g, "'")}`,
|
|
1844
|
+
]);
|
|
1845
|
+
// The address reads as typed, too.
|
|
1846
|
+
expect(html).toContain(`>${address.replace(/'/g, "'")}</a>`);
|
|
1847
|
+
},
|
|
1848
|
+
);
|
|
1849
|
+
|
|
1850
|
+
test.each(REPORTERS)(
|
|
1851
|
+
"in Slack: %s (name %s)",
|
|
1852
|
+
(address: string, reporterName: string | undefined) => {
|
|
1853
|
+
const note: string = getIncidentFormReporterNote({
|
|
1854
|
+
formName: "Report a Problem",
|
|
1855
|
+
reporterName: reporterName,
|
|
1856
|
+
reporterEmail: address,
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
const sections: string = JSON.stringify(
|
|
1860
|
+
SlackUtil.getMarkdownBlocks({
|
|
1861
|
+
payloadMarkdownBlock: {
|
|
1862
|
+
_type: "WorkspacePayloadMarkdown",
|
|
1863
|
+
text: note,
|
|
1864
|
+
},
|
|
1865
|
+
}),
|
|
1866
|
+
);
|
|
1867
|
+
|
|
1868
|
+
expect(sections).toContain(
|
|
1869
|
+
JSON.stringify(`<mailto:${address}|${address}>`).slice(1, -1),
|
|
1870
|
+
);
|
|
1871
|
+
},
|
|
1872
|
+
);
|
|
1873
|
+
|
|
1874
|
+
// Where a mail client sends a mailto: link: its path, nothing after it.
|
|
1875
|
+
function mailtoAddress(href: string): string {
|
|
1876
|
+
const link: URL = new URL(href);
|
|
1877
|
+
|
|
1878
|
+
expect(link.protocol).toBe("mailto:");
|
|
1879
|
+
expect(link.search).toBe("");
|
|
1880
|
+
expect(link.hash).toBe("");
|
|
1881
|
+
|
|
1882
|
+
return decodeURIComponent(link.pathname);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// A link as slackify writes it: <address|text>.
|
|
1886
|
+
const SLACK_MAILTO_LINK: RegExp = /<mailto:([^|>]+)\|([^>]+)>/;
|
|
1887
|
+
|
|
1888
|
+
/*
|
|
1889
|
+
* An autolink cannot carry every valid address whole: in a mailto: link
|
|
1890
|
+
* a mail client reads "#" as a fragment, "?" as headers and "%41" as an
|
|
1891
|
+
* escape, so <a%41@example.com> would write to aA@example.com - and one
|
|
1892
|
+
* starting with "!" or "#" would open like a Slack control sequence.
|
|
1893
|
+
* The renderers differ too: marked (the owners' email) drops "^" from
|
|
1894
|
+
* its autolink rule, so <jane^doe@corp.example> linked doe@corp.example,
|
|
1895
|
+
* and micromark (the dashboard, and slackify) takes no "!" in one, so
|
|
1896
|
+
* <first.last!ops@corp.example> linked ops@corp.example. Such an address
|
|
1897
|
+
* is linked explicitly, what a mailto: link may not carry as it is
|
|
1898
|
+
* percent-encoded: it still writes to exactly the address given.
|
|
1899
|
+
*/
|
|
1900
|
+
test.each([
|
|
1901
|
+
["!bang@example.com"],
|
|
1902
|
+
["#ops@corp.example"],
|
|
1903
|
+
["a#b@example.com"],
|
|
1904
|
+
["a?b@example.com"],
|
|
1905
|
+
["a%41@example.com"],
|
|
1906
|
+
["jane^doe@corp.example"],
|
|
1907
|
+
["^jane@corp.example"],
|
|
1908
|
+
["jane^@corp.example"],
|
|
1909
|
+
["first.last!ops@corp.example"],
|
|
1910
|
+
["a/b@example.com"],
|
|
1911
|
+
["a=b@example.com"],
|
|
1912
|
+
["a{b}@example.com"],
|
|
1913
|
+
["a|b@example.com"],
|
|
1914
|
+
["a`b@example.com"],
|
|
1915
|
+
])(
|
|
1916
|
+
"links %s to itself, not to what a mail client would make of it",
|
|
1917
|
+
async (address: string) => {
|
|
1918
|
+
const note: string = getIncidentFormReporterNote({
|
|
1919
|
+
formName: "Report a Problem",
|
|
1920
|
+
reporterName: "Jane Doe",
|
|
1921
|
+
reporterEmail: address,
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
expect(note).not.toContain(`<${address}>`);
|
|
1925
|
+
|
|
1926
|
+
const html: string = await Markdown.convertToHTML(
|
|
1927
|
+
note,
|
|
1928
|
+
MarkdownContentType.Email,
|
|
1929
|
+
);
|
|
1930
|
+
|
|
1931
|
+
const links: Array<string> = hrefs(html);
|
|
1932
|
+
|
|
1933
|
+
expect(links).toHaveLength(1);
|
|
1934
|
+
expect(mailtoAddress(links[0]!)).toBe(address);
|
|
1935
|
+
expect(html).toContain(`>${address}</a>`);
|
|
1936
|
+
|
|
1937
|
+
const slack: string = JSON.stringify(
|
|
1938
|
+
SlackUtil.getMarkdownBlocks({
|
|
1939
|
+
payloadMarkdownBlock: {
|
|
1940
|
+
_type: "WorkspacePayloadMarkdown",
|
|
1941
|
+
text: note,
|
|
1942
|
+
},
|
|
1943
|
+
}),
|
|
1944
|
+
);
|
|
1945
|
+
|
|
1946
|
+
const slackLink: RegExpExecArray | null = SLACK_MAILTO_LINK.exec(slack);
|
|
1947
|
+
|
|
1948
|
+
expect(slackLink).not.toBeNull();
|
|
1949
|
+
expect(mailtoAddress(`mailto:${slackLink![1]!}`)).toBe(address);
|
|
1950
|
+
expect(slackLink![2]).toBe(address);
|
|
1951
|
+
},
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1954
|
+
// What a mailto: link may carry as it is (RFC 6068), the rest encoded.
|
|
1955
|
+
const MAILTO_ADDRESS: RegExp =
|
|
1956
|
+
/^(?:[A-Za-z0-9\-._~!$'()*+,;:@]|%[0-9A-F]{2})+$/;
|
|
1957
|
+
|
|
1958
|
+
// The address's link at the end of a note, written either way.
|
|
1959
|
+
const NOTE_EXPLICIT_LINK: RegExp = /\]\((mailto:[^)]*)\)\)\.$/;
|
|
1960
|
+
const NOTE_AUTOLINK: RegExp = /\(<([^>]+)>\)\.$/;
|
|
1961
|
+
|
|
1962
|
+
function withoutEntities(text: string): string {
|
|
1963
|
+
return text
|
|
1964
|
+
.replace(/</g, "<")
|
|
1965
|
+
.replace(/>/g, ">")
|
|
1966
|
+
.replace(/"/g, '"')
|
|
1967
|
+
.replace(/'/g, "'")
|
|
1968
|
+
.replace(/&/g, "&");
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/*
|
|
1972
|
+
* Every character an address may hold (WHOLE_EMAIL_ADDRESS), at the
|
|
1973
|
+
* start, inside and at the end of its local part: the owners' email,
|
|
1974
|
+
* Slack and the dashboard each link exactly the address given, and the
|
|
1975
|
+
* note's own mailto: link carries nothing a mail client could read as
|
|
1976
|
+
* anything but the address.
|
|
1977
|
+
*/
|
|
1978
|
+
test("links an address holding any character an address may to exactly that address, in every renderer", async () => {
|
|
1979
|
+
const addresses: Array<string> = [];
|
|
1980
|
+
|
|
1981
|
+
for (const character of "!#$%&'*+/=?^_`{|}~-") {
|
|
1982
|
+
addresses.push(
|
|
1983
|
+
`${character}jane@corp.example`,
|
|
1984
|
+
`ja${character}ne@corp.example`,
|
|
1985
|
+
`jane${character}@corp.example`,
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
const notes: Array<string> = addresses.map((address: string): string => {
|
|
1990
|
+
return getIncidentFormReporterNote({
|
|
1991
|
+
formName: "Report a Problem",
|
|
1992
|
+
reporterName: "Jane Doe",
|
|
1993
|
+
reporterEmail: address,
|
|
1994
|
+
});
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
const dashboard: Array<string> = renderAsDashboard(notes);
|
|
1998
|
+
|
|
1999
|
+
for (const [index, address] of addresses.entries()) {
|
|
2000
|
+
const note: string = notes[index]!;
|
|
2001
|
+
|
|
2002
|
+
const link: string =
|
|
2003
|
+
NOTE_EXPLICIT_LINK.exec(note)?.[1] ||
|
|
2004
|
+
`mailto:${NOTE_AUTOLINK.exec(note)![1]}`;
|
|
2005
|
+
|
|
2006
|
+
expect({ address: address, link: link }).toEqual({
|
|
2007
|
+
address: address,
|
|
2008
|
+
link: expect.stringMatching(/^mailto:/),
|
|
2009
|
+
});
|
|
2010
|
+
expect(link.slice("mailto:".length)).toMatch(MAILTO_ADDRESS);
|
|
2011
|
+
|
|
2012
|
+
const email: Array<string> = hrefsOf(
|
|
2013
|
+
await Markdown.convertToHTML(note, MarkdownContentType.Email),
|
|
2014
|
+
);
|
|
2015
|
+
|
|
2016
|
+
expect({ address: address, email: email.map(mailtoAddress) }).toEqual({
|
|
2017
|
+
address: address,
|
|
2018
|
+
email: [address],
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
expect({
|
|
2022
|
+
address: address,
|
|
2023
|
+
dashboard: hrefsOf(dashboard[index]!).map(mailtoAddress),
|
|
2024
|
+
}).toEqual({ address: address, dashboard: [address] });
|
|
2025
|
+
|
|
2026
|
+
const slack: RegExpExecArray | null = SLACK_MAILTO_LINK.exec(
|
|
2027
|
+
JSON.parse(
|
|
2028
|
+
JSON.stringify(
|
|
2029
|
+
SlackUtil.getMarkdownBlocks({
|
|
2030
|
+
payloadMarkdownBlock: {
|
|
2031
|
+
_type: "WorkspacePayloadMarkdown",
|
|
2032
|
+
text: note,
|
|
2033
|
+
},
|
|
2034
|
+
}),
|
|
2035
|
+
),
|
|
2036
|
+
)[0].text.text as string,
|
|
2037
|
+
);
|
|
2038
|
+
|
|
2039
|
+
expect({
|
|
2040
|
+
address: address,
|
|
2041
|
+
slack: slack
|
|
2042
|
+
? [
|
|
2043
|
+
mailtoAddress(`mailto:${withoutEntities(slack[1]!)}`),
|
|
2044
|
+
withoutEntities(slack[2]!),
|
|
2045
|
+
]
|
|
2046
|
+
: null,
|
|
2047
|
+
}).toEqual({ address: address, slack: [address, address] });
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
test("escapes a value that is not one whole address, rather than linking it", () => {
|
|
2052
|
+
expect(
|
|
2053
|
+
getIncidentFormReporterNote({
|
|
2054
|
+
formName: "Form",
|
|
2055
|
+
reporterEmail: "Jane <jane@example.com>",
|
|
2056
|
+
}),
|
|
2057
|
+
).toBe(
|
|
2058
|
+
"Reported through the incident form **Form** by Jane \\<jane@example.com\\>.",
|
|
2059
|
+
);
|
|
2060
|
+
});
|
|
2061
|
+
});
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
describe("IncidentFormService.submitPublicForm - after the incident exists", () => {
|
|
2065
|
+
test("a failure recording the submission is logged, and the note still happens", async () => {
|
|
2066
|
+
submissionCreate.mockRejectedValue(new Error("insert failed"));
|
|
2067
|
+
|
|
2068
|
+
await expect(submit()).resolves.toHaveProperty("incidentNumber", "INC-42");
|
|
2069
|
+
expect(noteCreate).toHaveBeenCalledTimes(1);
|
|
2070
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
2071
|
+
expect.stringContaining("could not record the submission for incident"),
|
|
2072
|
+
expect.anything(),
|
|
2073
|
+
);
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
test("a failure writing the note is logged, not thrown", async () => {
|
|
2077
|
+
noteCreate.mockRejectedValue(new Error("insert failed"));
|
|
2078
|
+
|
|
2079
|
+
await expect(submit()).resolves.toHaveProperty("incidentNumber", "INC-42");
|
|
2080
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
2081
|
+
expect.stringContaining("could not add the reporter's private note to"),
|
|
2082
|
+
expect.anything(),
|
|
2083
|
+
);
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
test("everything after the incident failing still answers with its number", async () => {
|
|
2087
|
+
storedForm = withTemplate();
|
|
2088
|
+
submissionCreate.mockRejectedValue(new Error("b"));
|
|
2089
|
+
noteCreate.mockRejectedValue(new Error("c"));
|
|
2090
|
+
|
|
2091
|
+
await expect(submit()).resolves.toEqual({
|
|
2092
|
+
incidentNumber: "INC-42",
|
|
2093
|
+
successMessage: "Thanks - **we are on it**.",
|
|
2094
|
+
});
|
|
2095
|
+
});
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
describe("IncidentFormService.submitPublicForm - when the incident cannot be declared", () => {
|
|
2099
|
+
test("answers with a message the reporter can act on, logs the reason, and does nothing else", async () => {
|
|
2100
|
+
incidentCreate.mockRejectedValue(
|
|
2101
|
+
new BadDataException(
|
|
2102
|
+
"Created incident state not found for this project. Please add created incident state from settings.",
|
|
2103
|
+
),
|
|
2104
|
+
);
|
|
2105
|
+
|
|
2106
|
+
const error: Exception | undefined = await refusal(submit());
|
|
2107
|
+
|
|
2108
|
+
expect(error).toBeInstanceOf(ServerException);
|
|
2109
|
+
expect(error?.code).toBe(500);
|
|
2110
|
+
expect(error?.message).toBe(INCIDENT_FORM_SUBMIT_FAILED_MESSAGE);
|
|
2111
|
+
expect(logger.error).toHaveBeenCalled();
|
|
2112
|
+
expect(addOwners).not.toHaveBeenCalled();
|
|
2113
|
+
expect(submissionCreate).not.toHaveBeenCalled();
|
|
2114
|
+
expect(noteCreate).not.toHaveBeenCalled();
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
test("never hands the reporter an internal message or id", async () => {
|
|
2118
|
+
const internalId: string = ObjectID.generate().toString();
|
|
2119
|
+
incidentCreate.mockRejectedValue(
|
|
2120
|
+
new BadDataException(`Incident Severity ${internalId} is not in project`),
|
|
2121
|
+
);
|
|
2122
|
+
|
|
2123
|
+
const error: Exception | undefined = await refusal(submit());
|
|
2124
|
+
|
|
2125
|
+
expect(error?.message).not.toContain(internalId);
|
|
2126
|
+
});
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
describe("IncidentFormService.submitPublicForm - the answer", () => {
|
|
2130
|
+
test("is the incident's number and the form's success message", async () => {
|
|
2131
|
+
expect(await submit()).toEqual({
|
|
2132
|
+
incidentNumber: "INC-42",
|
|
2133
|
+
successMessage: "Thanks - **we are on it**.",
|
|
2134
|
+
});
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
test("numbers an incident of a project without a prefix with a #", async () => {
|
|
2138
|
+
setNull(createdIncident, "incidentNumberWithPrefix");
|
|
2139
|
+
|
|
2140
|
+
expect((await submit()).incidentNumber).toBe("#42");
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
test("leaves the number out when the incident came back without one", async () => {
|
|
2144
|
+
setNull(createdIncident, "incidentNumberWithPrefix");
|
|
2145
|
+
setNull(createdIncident, "incidentNumber");
|
|
2146
|
+
|
|
2147
|
+
expect(await submit()).not.toHaveProperty("incidentNumber");
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
test.each([
|
|
2151
|
+
["empty", ""],
|
|
2152
|
+
["blank", " \n "],
|
|
2153
|
+
["unset", null],
|
|
2154
|
+
])(
|
|
2155
|
+
"leaves out a success message that is %s",
|
|
2156
|
+
async (_label: string, successMessage: string | null) => {
|
|
2157
|
+
storedForm = buildForm();
|
|
2158
|
+
(storedForm as unknown as Record<string, unknown>)["successMessage"] =
|
|
2159
|
+
successMessage;
|
|
2160
|
+
|
|
2161
|
+
expect(await submit()).toEqual({ incidentNumber: "INC-42" });
|
|
2162
|
+
},
|
|
2163
|
+
);
|
|
2164
|
+
|
|
2165
|
+
test("never includes the incident's id, the form's id or its project", async () => {
|
|
2166
|
+
const sent: string = JSON.stringify(await submit());
|
|
2167
|
+
|
|
2168
|
+
expect(sent).not.toContain(INCIDENT_ID);
|
|
2169
|
+
expect(sent).not.toContain(FORM_ID);
|
|
2170
|
+
expect(sent).not.toContain(PROJECT_ID.toString());
|
|
2171
|
+
});
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
describe("IncidentFormService.submitPublicForm - the checks before anything is declared", () => {
|
|
2175
|
+
test("verifies the captcha, when the instance has one on, with the token and the client address", async () => {
|
|
2176
|
+
captchaEnabled.mockReturnValue(true);
|
|
2177
|
+
|
|
2178
|
+
await submit({ captchaToken: "token-from-hcaptcha" });
|
|
2179
|
+
|
|
2180
|
+
expect(verifyCaptcha).toHaveBeenCalledWith({
|
|
2181
|
+
token: "token-from-hcaptcha",
|
|
2182
|
+
remoteIp: CAPTCHA_IP,
|
|
2183
|
+
});
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
test("does not verify a captcha when the instance has none, even if a token is sent", async () => {
|
|
2187
|
+
await submit({ captchaToken: "token-from-hcaptcha" });
|
|
2188
|
+
|
|
2189
|
+
expect(verifyCaptcha).not.toHaveBeenCalled();
|
|
2190
|
+
expect(incidentCreate).toHaveBeenCalledTimes(1);
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
test("refuses a failed captcha before reading or declaring anything", async () => {
|
|
2194
|
+
captchaEnabled.mockReturnValue(true);
|
|
2195
|
+
storedForm = buildForm({
|
|
2196
|
+
customFieldSettings: FORM_QUESTIONS,
|
|
2197
|
+
allowReporterToChooseSeverity: true,
|
|
2198
|
+
});
|
|
2199
|
+
verifyCaptcha.mockRejectedValue(
|
|
2200
|
+
new BadDataException("Captcha verification failed. Please try again."),
|
|
2201
|
+
);
|
|
2202
|
+
|
|
2203
|
+
const error: Exception | undefined = await refusal(submit());
|
|
2204
|
+
|
|
2205
|
+
expect(error).toBeInstanceOf(BadDataException);
|
|
2206
|
+
expect(error?.message).toBe(
|
|
2207
|
+
"Captcha verification failed. Please try again.",
|
|
2208
|
+
);
|
|
2209
|
+
expect(customFieldFindBy).not.toHaveBeenCalled();
|
|
2210
|
+
expect(severityFindBy).not.toHaveBeenCalled();
|
|
2211
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
test("checks the captcha before the answers", async () => {
|
|
2215
|
+
captchaEnabled.mockReturnValue(true);
|
|
2216
|
+
verifyCaptcha.mockRejectedValue(
|
|
2217
|
+
new BadDataException(
|
|
2218
|
+
"Captcha token is missing. Please complete the verification challenge.",
|
|
2219
|
+
),
|
|
2220
|
+
);
|
|
2221
|
+
|
|
2222
|
+
const error: Exception | undefined = await refusal(
|
|
2223
|
+
submit({ answers: { title: "" } }),
|
|
2224
|
+
);
|
|
2225
|
+
|
|
2226
|
+
expect(error?.message).toBe(
|
|
2227
|
+
"Captcha token is missing. Please complete the verification challenge.",
|
|
2228
|
+
);
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
test.each([
|
|
2232
|
+
["a junk link", { shareKey: "junk" }],
|
|
2233
|
+
["an unknown link", { shareKey: OTHER_SHARE_KEY }],
|
|
2234
|
+
])(
|
|
2235
|
+
"refuses %s as not available, without the captcha provider or a create",
|
|
2236
|
+
async (_label: string, change: { shareKey: string }) => {
|
|
2237
|
+
captchaEnabled.mockReturnValue(true);
|
|
2238
|
+
|
|
2239
|
+
const error: Exception | undefined = await refusal(submit(change));
|
|
2240
|
+
|
|
2241
|
+
expect(error).toBeInstanceOf(NotFoundException);
|
|
2242
|
+
expect(error?.message).toBe(INCIDENT_FORM_NOT_AVAILABLE_MESSAGE);
|
|
2243
|
+
expect(verifyCaptcha).not.toHaveBeenCalled();
|
|
2244
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2245
|
+
},
|
|
2246
|
+
);
|
|
2247
|
+
|
|
2248
|
+
test("refuses a form that is turned off as not available", async () => {
|
|
2249
|
+
storedForm = buildForm({ isEnabled: false });
|
|
2250
|
+
|
|
2251
|
+
const error: Exception | undefined = await refusal(submit());
|
|
2252
|
+
|
|
2253
|
+
expect(error).toBeInstanceOf(NotFoundException);
|
|
2254
|
+
expect(error?.message).toBe(INCIDENT_FORM_NOT_AVAILABLE_MESSAGE);
|
|
2255
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
test("refuses a form of a project off plan with the same answer as an unknown link", async () => {
|
|
2259
|
+
jest
|
|
2260
|
+
.spyOn(IncidentFormService, "isProjectOnPlan")
|
|
2261
|
+
.mockResolvedValue(false as never);
|
|
2262
|
+
|
|
2263
|
+
const offPlan: Exception | undefined = await refusal(submit());
|
|
2264
|
+
const unknown: Exception | undefined = await refusal(
|
|
2265
|
+
submit({ shareKey: OTHER_SHARE_KEY }),
|
|
2266
|
+
);
|
|
2267
|
+
|
|
2268
|
+
expect(offPlan).toBeInstanceOf(NotFoundException);
|
|
2269
|
+
expect([offPlan?.code, offPlan?.message]).toEqual([
|
|
2270
|
+
unknown?.code,
|
|
2271
|
+
unknown?.message,
|
|
2272
|
+
]);
|
|
2273
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
test("refuses a network the form does not allow, before the captcha", async () => {
|
|
2277
|
+
captchaEnabled.mockReturnValue(true);
|
|
2278
|
+
storedForm = buildForm({ ipWhitelist: "198.51.100.0/24" });
|
|
2279
|
+
|
|
2280
|
+
const error: Exception | undefined = await refusal(submit());
|
|
2281
|
+
|
|
2282
|
+
expect(error).toBeInstanceOf(ForbiddenException);
|
|
2283
|
+
expect(error?.message).toBe(INCIDENT_FORM_NETWORK_NOT_ALLOWED_MESSAGE);
|
|
2284
|
+
expect(verifyCaptcha).not.toHaveBeenCalled();
|
|
2285
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
test("lets an allowed network through to declare", async () => {
|
|
2289
|
+
storedForm = buildForm({ ipWhitelist: `198.51.100.0/24\n${CLIENT_IP}` });
|
|
2290
|
+
|
|
2291
|
+
await submit();
|
|
2292
|
+
|
|
2293
|
+
expect(incidentCreate).toHaveBeenCalledTimes(1);
|
|
2294
|
+
});
|
|
2295
|
+
|
|
2296
|
+
test("refuses a request without an address when the form has an allowlist", async () => {
|
|
2297
|
+
storedForm = buildForm({ ipWhitelist: CLIENT_IP });
|
|
2298
|
+
|
|
2299
|
+
expect(await refusal(submit({ clientIp: undefined }))).toBeInstanceOf(
|
|
2300
|
+
ForbiddenException,
|
|
2301
|
+
);
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
test("looks the form up once, by its share key, as root", async () => {
|
|
2305
|
+
await submit();
|
|
2306
|
+
|
|
2307
|
+
expect(formFindOneBy).toHaveBeenCalledTimes(1);
|
|
2308
|
+
|
|
2309
|
+
const findBy: {
|
|
2310
|
+
query: Record<string, unknown>;
|
|
2311
|
+
props: Record<string, unknown>;
|
|
2312
|
+
} = formFindOneBy.mock.calls[0]![0] as never;
|
|
2313
|
+
|
|
2314
|
+
expect(Object.keys(findBy.query)).toEqual(["shareKey"]);
|
|
2315
|
+
expect(String(findBy.query["shareKey"])).toBe(SHARE_KEY);
|
|
2316
|
+
expect(findBy.props).toEqual({ isRoot: true });
|
|
2317
|
+
});
|
|
2318
|
+
});
|
|
2319
|
+
|
|
2320
|
+
/*
|
|
2321
|
+
* The form's hourly ceiling bounds the incidents - and pages - its link can
|
|
2322
|
+
* cause, so only a submission about to declare one may spend it. Were a
|
|
2323
|
+
* refused request to count, anyone holding the link - outside the IP
|
|
2324
|
+
* allowlist, or unable to solve the captcha - could use it up and lock the
|
|
2325
|
+
* form for everybody.
|
|
2326
|
+
*/
|
|
2327
|
+
describe("IncidentFormService.submitPublicForm - the form's hourly ceiling", () => {
|
|
2328
|
+
test("is spent once, for the form's link, after every check and right before the incident is declared", async () => {
|
|
2329
|
+
await submit();
|
|
2330
|
+
|
|
2331
|
+
expect(reserveFormSubmission).toHaveBeenCalledTimes(1);
|
|
2332
|
+
expect(reserveFormSubmission).toHaveBeenCalledWith({ shareKey: SHARE_KEY });
|
|
2333
|
+
expect(reserveFormSubmission.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
2334
|
+
incidentCreate.mock.invocationCallOrder[0]!,
|
|
2335
|
+
);
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
test.each([
|
|
2339
|
+
[
|
|
2340
|
+
"a link no form holds",
|
|
2341
|
+
(): void => {
|
|
2342
|
+
storedForm = null;
|
|
2343
|
+
},
|
|
2344
|
+
VALID_ANSWERS,
|
|
2345
|
+
],
|
|
2346
|
+
[
|
|
2347
|
+
"a form that is turned off",
|
|
2348
|
+
(): void => {
|
|
2349
|
+
storedForm = buildForm({ isEnabled: false });
|
|
2350
|
+
},
|
|
2351
|
+
VALID_ANSWERS,
|
|
2352
|
+
],
|
|
2353
|
+
[
|
|
2354
|
+
"a project off plan",
|
|
2355
|
+
(): void => {
|
|
2356
|
+
jest
|
|
2357
|
+
.spyOn(IncidentFormService, "isProjectOnPlan")
|
|
2358
|
+
.mockResolvedValue(false as never);
|
|
2359
|
+
},
|
|
2360
|
+
VALID_ANSWERS,
|
|
2361
|
+
],
|
|
2362
|
+
[
|
|
2363
|
+
"a network the form does not allow",
|
|
2364
|
+
(): void => {
|
|
2365
|
+
storedForm = buildForm({ ipWhitelist: "198.51.100.0/24" });
|
|
2366
|
+
},
|
|
2367
|
+
VALID_ANSWERS,
|
|
2368
|
+
],
|
|
2369
|
+
[
|
|
2370
|
+
"a failed captcha",
|
|
2371
|
+
(): void => {
|
|
2372
|
+
captchaEnabled.mockReturnValue(true);
|
|
2373
|
+
verifyCaptcha.mockRejectedValue(
|
|
2374
|
+
new BadDataException(
|
|
2375
|
+
"Captcha verification failed. Please try again.",
|
|
2376
|
+
),
|
|
2377
|
+
);
|
|
2378
|
+
},
|
|
2379
|
+
VALID_ANSWERS,
|
|
2380
|
+
],
|
|
2381
|
+
[
|
|
2382
|
+
"answers that do not pass",
|
|
2383
|
+
(): void => {
|
|
2384
|
+
// The answers below have no title.
|
|
2385
|
+
},
|
|
2386
|
+
{ ...VALID_ANSWERS, title: "" },
|
|
2387
|
+
],
|
|
2388
|
+
[
|
|
2389
|
+
"no severity left",
|
|
2390
|
+
(): void => {
|
|
2391
|
+
storedForm = buildForm();
|
|
2392
|
+
setNull(storedForm, "incidentSeverityId");
|
|
2393
|
+
},
|
|
2394
|
+
VALID_ANSWERS,
|
|
2395
|
+
],
|
|
2396
|
+
])(
|
|
2397
|
+
"is never spent by a submission refused for %s",
|
|
2398
|
+
async (_label: string, arrange: () => void, answers: JSONObject) => {
|
|
2399
|
+
arrange();
|
|
2400
|
+
|
|
2401
|
+
const error: Exception | undefined = await refusal(submit({ answers }));
|
|
2402
|
+
|
|
2403
|
+
expect(error).toBeDefined();
|
|
2404
|
+
expect(reserveFormSubmission).not.toHaveBeenCalled();
|
|
2405
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2406
|
+
},
|
|
2407
|
+
);
|
|
2408
|
+
|
|
2409
|
+
test("once the hour's allowance is used, nothing is declared and the refusal says when to come back", async () => {
|
|
2410
|
+
storedForm = withTemplate();
|
|
2411
|
+
reserveFormSubmission.mockRejectedValue(
|
|
2412
|
+
new IncidentFormCeilingException(1800),
|
|
2413
|
+
);
|
|
2414
|
+
|
|
2415
|
+
const error: Exception | undefined = await refusal(submit());
|
|
2416
|
+
|
|
2417
|
+
expect(error).toBeInstanceOf(IncidentFormCeilingException);
|
|
2418
|
+
expect(error?.code).toBe(429);
|
|
2419
|
+
expect(error?.message).toBe(INCIDENT_FORM_TOTAL_RATE_LIMIT_MESSAGE);
|
|
2420
|
+
expect((error as IncidentFormCeilingException).retryAfterSeconds).toBe(
|
|
2421
|
+
1800,
|
|
2422
|
+
);
|
|
2423
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2424
|
+
expect(ownerUserFindBy).not.toHaveBeenCalled();
|
|
2425
|
+
expect(submissionCreate).not.toHaveBeenCalled();
|
|
2426
|
+
expect(noteCreate).not.toHaveBeenCalled();
|
|
2427
|
+
});
|
|
2428
|
+
|
|
2429
|
+
test("when the ceiling cannot be counted, nothing is declared (503)", async () => {
|
|
2430
|
+
reserveFormSubmission.mockRejectedValue(
|
|
2431
|
+
new ServiceUnavailableException(
|
|
2432
|
+
INCIDENT_FORM_RATE_LIMIT_UNAVAILABLE_MESSAGE,
|
|
2433
|
+
),
|
|
2434
|
+
);
|
|
2435
|
+
|
|
2436
|
+
const error: Exception | undefined = await refusal(submit());
|
|
2437
|
+
|
|
2438
|
+
expect(error?.code).toBe(503);
|
|
2439
|
+
expect(error?.message).toBe(INCIDENT_FORM_RATE_LIMIT_UNAVAILABLE_MESSAGE);
|
|
2440
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2441
|
+
});
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
/*
|
|
2445
|
+
* Making the description and the Rich text answers safe to show is the one
|
|
2446
|
+
* step whose cost grows with the Markdown in them, and it cannot refuse a
|
|
2447
|
+
* submission. So it is the last step before the incident is declared: a
|
|
2448
|
+
* submission refused on the way - for its title, its severity or the
|
|
2449
|
+
* form's ceiling - never pays for it, and every one that does is bounded by
|
|
2450
|
+
* that ceiling.
|
|
2451
|
+
*/
|
|
2452
|
+
describe("IncidentFormService.submitPublicForm - reading the reporter's Markdown", () => {
|
|
2453
|
+
const WITH_AN_IMAGE: JSONObject = {
|
|
2454
|
+
...VALID_ANSWERS,
|
|
2455
|
+
description: "Broken: ",
|
|
2456
|
+
};
|
|
2457
|
+
|
|
2458
|
+
test("happens once the form's ceiling is spent, before the incident is declared", async () => {
|
|
2459
|
+
const lex: ReturnType<typeof jest.spyOn> = jest.spyOn(
|
|
2460
|
+
Lexer.prototype,
|
|
2461
|
+
"lex",
|
|
2462
|
+
);
|
|
2463
|
+
|
|
2464
|
+
await submit({ answers: WITH_AN_IMAGE });
|
|
2465
|
+
|
|
2466
|
+
expect(lex).toHaveBeenCalled();
|
|
2467
|
+
expect(reserveFormSubmission.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
2468
|
+
lex.mock.invocationCallOrder[0]!,
|
|
2469
|
+
);
|
|
2470
|
+
expect(lex.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
2471
|
+
incidentCreate.mock.invocationCallOrder[0]!,
|
|
2472
|
+
);
|
|
2473
|
+
expect(createCall().data.description).toBe(
|
|
2474
|
+
"Broken: \\",
|
|
2475
|
+
);
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2478
|
+
test.each([
|
|
2479
|
+
[
|
|
2480
|
+
"a title that no longer fits",
|
|
2481
|
+
(): void => {
|
|
2482
|
+
// The answers below have the title.
|
|
2483
|
+
},
|
|
2484
|
+
{ ...WITH_AN_IMAGE, title: "<!".repeat(250) },
|
|
2485
|
+
],
|
|
2486
|
+
[
|
|
2487
|
+
"no severity left",
|
|
2488
|
+
(): void => {
|
|
2489
|
+
storedForm = buildForm();
|
|
2490
|
+
setNull(storedForm, "incidentSeverityId");
|
|
2491
|
+
},
|
|
2492
|
+
WITH_AN_IMAGE,
|
|
2493
|
+
],
|
|
2494
|
+
[
|
|
2495
|
+
"the form's ceiling",
|
|
2496
|
+
(): void => {
|
|
2497
|
+
reserveFormSubmission.mockRejectedValue(
|
|
2498
|
+
new IncidentFormCeilingException(1800),
|
|
2499
|
+
);
|
|
2500
|
+
},
|
|
2501
|
+
WITH_AN_IMAGE,
|
|
2502
|
+
],
|
|
2503
|
+
])(
|
|
2504
|
+
"never happens for a submission refused for %s",
|
|
2505
|
+
async (_label: string, arrange: () => void, answers: JSONObject) => {
|
|
2506
|
+
arrange();
|
|
2507
|
+
|
|
2508
|
+
const lex: ReturnType<typeof jest.spyOn> = jest.spyOn(
|
|
2509
|
+
Lexer.prototype,
|
|
2510
|
+
"lex",
|
|
2511
|
+
);
|
|
2512
|
+
|
|
2513
|
+
const error: Exception | undefined = await refusal(submit({ answers }));
|
|
2514
|
+
|
|
2515
|
+
expect(error).toBeDefined();
|
|
2516
|
+
expect(lex).not.toHaveBeenCalled();
|
|
2517
|
+
expect(incidentCreate).not.toHaveBeenCalled();
|
|
2518
|
+
},
|
|
2519
|
+
);
|
|
2520
|
+
});
|